~cytrogen/masto-fe

075887e1d698601ebdff434ad79c6be3a0983e89 — Claire 2 years ago 71f8c45 + ea10feb
Merge commit 'ea10febd257b5b729a50aeb3218389763f5f4b97' into glitch-soc/merge-upstream
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/helpers/accounts_helper.rb => app/helpers/accounts_helper.rb +1 -1
@@ 22,7 22,7 @@ module AccountsHelper
  def account_action_button(account)
    return if account.memorial? || account.moved?

    link_to ActivityPub::TagManager.instance.url_for(account), class: 'button logo-button', target: '_new' do
    link_to ActivityPub::TagManager.instance.url_for(account), class: 'button', target: '_new' do
      safe_join([logo_as_symbol, t('accounts.follow')])
    end
  end

M app/javascript/mastodon/features/account/components/header.jsx => app/javascript/mastodon/features/account/components/header.jsx +4 -4
@@ 264,14 264,14 @@ class Header extends ImmutablePureComponent {
      if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded
        actionBtn = '';
      } else if (account.getIn(['relationship', 'requested'])) {
        actionBtn = <Button className={classNames('logo-button', { 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
        actionBtn = <Button className={classNames({ 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
      } else if (!account.getIn(['relationship', 'blocking'])) {
        actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']), 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={signedIn ? this.props.onFollow : this.props.onInteractionModal} />;
        actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames({ 'button--destructive': account.getIn(['relationship', 'following']), 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={signedIn ? this.props.onFollow : this.props.onInteractionModal} />;
      } else if (account.getIn(['relationship', 'blocking'])) {
        actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />;
        actionBtn = <Button text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />;
      }
    } else {
      actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />;
      actionBtn = <Button text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />;
    }

    if (account.get('moved') && !account.getIn(['relationship', 'following'])) {

M app/javascript/mastodon/features/directory/components/account_card.jsx => app/javascript/mastodon/features/directory/components/account_card.jsx +5 -5
@@ 160,16 160,16 @@ class AccountCard extends ImmutablePureComponent {
      if (!account.get('relationship')) { // Wait until the relationship is loaded
        actionBtn = '';
      } else if (account.getIn(['relationship', 'requested'])) {
        actionBtn = <Button className={classNames('logo-button')} text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.handleFollow} />;
        actionBtn = <Button  text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.handleFollow} />;
      } else if (account.getIn(['relationship', 'muting'])) {
        actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unmute)} onClick={this.handleMute} />;
        actionBtn = <Button  text={intl.formatMessage(messages.unmute)} onClick={this.handleMute} />;
      } else if (!account.getIn(['relationship', 'blocking'])) {
        actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.handleFollow} />;
        actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames({ 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.handleFollow} />;
      } else if (account.getIn(['relationship', 'blocking'])) {
        actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
        actionBtn = <Button  text={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
      }
    } else {
      actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.handleEditProfile} />;
      actionBtn = <Button  text={intl.formatMessage(messages.edit_profile)} onClick={this.handleEditProfile} />;
    }

    return (

M app/javascript/mastodon/features/explore/statuses.jsx => app/javascript/mastodon/features/explore/statuses.jsx +1 -0
@@ 52,6 52,7 @@ class Statuses extends PureComponent {

        <StatusList
          trackScroll
          timelineId='explore'
          statusIds={statusIds}
          scrollKey='explore-statuses'
          hasMore={hasMore}

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-light/diff.scss => app/javascript/styles/mastodon-light/diff.scss +0 -8
@@ 627,14 627,6 @@ html {
  }
}

.button.logo-button {
  color: $white;

  svg {
    fill: $white;
  }
}

.notification__filter-bar button.active::after,
.account__section-headline a.active::after {
  border-color: transparent transparent $white;

M app/javascript/styles/mastodon/components.scss => app/javascript/styles/mastodon/components.scss +1 -4
@@ 1670,10 1670,6 @@ a.account__display-name {
  color: inherit;
}

.detailed-status .button.logo-button {
  margin-bottom: 15px;
}

.detailed-status__display-name {
  color: $darker-text-color;
  display: flex;


@@ 5784,6 5780,7 @@ a.status-card.compact:hover {
  &__toggle {
    display: flex;
    align-items: center;
    margin-bottom: 10px;

    & > span {
      font-size: 17px;

M app/javascript/styles/mastodon/statuses.scss => app/javascript/styles/mastodon/statuses.scss +0 -60
@@ 77,66 77,6 @@
  }
}

.button.logo-button {
  flex: 0 auto;
  font-size: 14px;
  background: darken($ui-highlight-color, 2%);
  color: $primary-text-color;
  text-transform: none;
  line-height: 1.2;
  height: auto;
  min-height: 36px;
  min-width: 88px;
  white-space: normal;
  overflow-wrap: break-word;
  hyphens: auto;
  padding: 0 15px;
  border: 0;

  svg {
    width: 20px;
    height: auto;
    vertical-align: middle;
    margin-inline-end: 5px;
    fill: $primary-text-color;
  }

  &:active,
  &:focus,
  &:hover {
    background: $ui-highlight-color;
  }

  &:disabled,
  &.disabled {
    &:active,
    &:focus,
    &:hover {
      background: $ui-primary-color;
    }
  }

  &.button--destructive {
    &:active,
    &:focus,
    &:hover {
      background: $error-red;
    }
  }

  @media screen and (max-width: $no-gap-breakpoint) {
    svg {
      display: none;
    }
  }
}

a.button.logo-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

.embed {
  .status__content[data-spoiler='folded'] {
    .e-content {

M app/mailers/notification_mailer.rb => app/mailers/notification_mailer.rb +32 -39
@@ 1,83 1,76 @@
# frozen_string_literal: true

class NotificationMailer < ApplicationMailer
  helper :accounts
  helper :statuses
  helper :accounts,
         :statuses,
         :routing

  helper RoutingHelper
  before_action :process_params
  before_action :set_status, only: [:mention, :favourite, :reblog]
  before_action :set_account, only: [:follow, :favourite, :reblog, :follow_request]

  def mention(recipient, notification)
    @me     = recipient
    @user   = recipient.user
    @type   = 'mention'
    @status = notification.target_status
  default to: -> { email_address_with_name(@user.email, @me.username) }

  def mention
    return unless @user.functional? && @status.present?

    locale_for_account(@me) do
      thread_by_conversation(@status.conversation)
      mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.mention.subject', name: @status.account.acct)
      mail subject: default_i18n_subject(name: @status.account.acct)
    end
  end

  def follow(recipient, notification)
    @me      = recipient
    @user    = recipient.user
    @type    = 'follow'
    @account = notification.from_account

  def follow
    return unless @user.functional?

    locale_for_account(@me) do
      mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.follow.subject', name: @account.acct)
      mail subject: default_i18n_subject(name: @account.acct)
    end
  end

  def favourite(recipient, notification)
    @me      = recipient
    @user    = recipient.user
    @type    = 'favourite'
    @account = notification.from_account
    @status  = notification.target_status

  def favourite
    return unless @user.functional? && @status.present?

    locale_for_account(@me) do
      thread_by_conversation(@status.conversation)
      mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.favourite.subject', name: @account.acct)
      mail subject: default_i18n_subject(name: @account.acct)
    end
  end

  def reblog(recipient, notification)
    @me      = recipient
    @user    = recipient.user
    @type    = 'reblog'
    @account = notification.from_account
    @status  = notification.target_status

  def reblog
    return unless @user.functional? && @status.present?

    locale_for_account(@me) do
      thread_by_conversation(@status.conversation)
      mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.reblog.subject', name: @account.acct)
      mail subject: default_i18n_subject(name: @account.acct)
    end
  end

  def follow_request(recipient, notification)
    @me      = recipient
    @user    = recipient.user
    @type    = 'follow_request'
    @account = notification.from_account

  def follow_request
    return unless @user.functional?

    locale_for_account(@me) do
      mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct)
      mail subject: default_i18n_subject(name: @account.acct)
    end
  end

  private

  def process_params
    @notification = params[:notification]
    @me = params[:recipient]
    @user = @me.user
    @type = action_name
  end

  def set_status
    @status = @notification.target_status
  end

  def set_account
    @account = @notification.from_account
  end

  def thread_by_conversation(conversation)
    return if conversation.nil?


M app/services/account_search_service.rb => app/services/account_search_service.rb +6 -2
@@ 133,8 133,12 @@ class AccountSearchService < BaseService
  end

  def must_clause
    fields = %w(username username.* display_name display_name.*)
    fields << 'text' << 'text.*' if options[:use_searchable_text]
    if options[:start_with_hashtag]
      fields = %w(text text.*)
    else
      fields = %w(username username.* display_name display_name.*)
      fields << 'text' << 'text.*' if options[:use_searchable_text]
    end

    [
      {

M app/services/activitypub/process_account_service.rb => app/services/activitypub/process_account_service.rb +3 -0
@@ 76,6 76,9 @@ class ActivityPub::ProcessAccountService < BaseService
    @account.suspended_at      = domain_block.created_at if auto_suspend?
    @account.suspension_origin = :local if auto_suspend?
    @account.silenced_at       = domain_block.created_at if auto_silence?

    set_immediate_protocol_attributes!

    @account.save
  end


M app/services/notify_service.rb => app/services/notify_service.rb +6 -1
@@ 162,7 162,12 @@ class NotifyService < BaseService
  end

  def send_email!
    NotificationMailer.public_send(@notification.type, @recipient, @notification).deliver_later(wait: 2.minutes) if NotificationMailer.respond_to?(@notification.type)
    return unless NotificationMailer.respond_to?(@notification.type)

    NotificationMailer
      .with(recipient: @recipient, notification: @notification)
      .public_send(@notification.type)
      .deliver_later(wait: 2.minutes)
  end

  def email_needed?

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 app/services/search_service.rb => app/services/search_service.rb +4 -3
@@ 33,7 33,8 @@ class SearchService < BaseService
      resolve: @resolve,
      offset: @offset,
      use_searchable_text: true,
      following: @following
      following: @following,
      start_with_hashtag: @query.start_with?('#')
    )
  end



@@ 91,11 92,11 @@ class SearchService < BaseService
  def full_text_searchable?
    return false unless Chewy.enabled?

    statuses_search? && !@account.nil? && !((@query.start_with?('#') || @query.include?('@')) && !@query.include?(' '))
    statuses_search? && !@account.nil? && !(@query.include?('@') && !@query.include?(' '))
  end

  def account_searchable?
    account_search? && !(@query.start_with?('#') || (@query.include?('@') && @query.include?(' ')))
    account_search? && !(@query.include?('@') && @query.include?(' '))
  end

  def hashtag_searchable?

M app/workers/merge_worker.rb => app/workers/merge_worker.rb +8 -1
@@ 5,7 5,14 @@ class MergeWorker
  include Redisable

  def perform(from_account_id, into_account_id)
    FeedManager.instance.merge_into_home(Account.find(from_account_id), Account.find(into_account_id))
    ApplicationRecord.connected_to(role: :primary) do
      @from_account = Account.find(from_account_id)
      @into_account = Account.find(into_account_id)
    end

    ApplicationRecord.connected_to(role: :read, prevent_writes: true) do
      FeedManager.instance.merge_into_home(@from_account, @into_account)
    end
  rescue ActiveRecord::RecordNotFound
    true
  ensure

M app/workers/regeneration_worker.rb => app/workers/regeneration_worker.rb +7 -2
@@ 6,8 6,13 @@ class RegenerationWorker
  sidekiq_options lock: :until_executed

  def perform(account_id, _ = :home)
    account = Account.find(account_id)
    PrecomputeFeedService.new.call(account)
    ApplicationRecord.connected_to(role: :primary) do
      @account = Account.find(account_id)
    end

    ApplicationRecord.connected_to(role: :read, prevent_writes: true) do
      PrecomputeFeedService.new.call(@account)
    end
  rescue ActiveRecord::RecordNotFound
    true
  end

M app/workers/unmerge_worker.rb => app/workers/unmerge_worker.rb +8 -1
@@ 6,7 6,14 @@ class UnmergeWorker
  sidekiq_options queue: 'pull'

  def perform(from_account_id, into_account_id)
    FeedManager.instance.unmerge_from_home(Account.find(from_account_id), Account.find(into_account_id))
    ApplicationRecord.connected_to(role: :primary) do
      @from_account = Account.find(from_account_id)
      @into_account = Account.find(into_account_id)
    end

    ApplicationRecord.connected_to(role: :read, prevent_writes: true) do
      FeedManager.instance.unmerge_from_home(@from_account, @into_account)
    end
  rescue ActiveRecord::RecordNotFound
    true
  end

M config/routes/admin.rb => config/routes/admin.rb +1 -1
@@ 68,7 68,7 @@ namespace :admin do
    end
  end

  resources :instances, only: [:index, :show, :destroy], constraints: { id: %r{[^/]+} } do
  resources :instances, only: [:index, :show, :destroy], constraints: { id: %r{[^/]+} }, format: 'html' do
    member do
      post :clear_delivery_errors
      post :restart_delivery

M spec/mailers/notification_mailer_spec.rb => spec/mailers/notification_mailer_spec.rb +16 -5
@@ 23,7 23,8 @@ RSpec.describe NotificationMailer do

  describe 'mention' do
    let(:mention) { Mention.create!(account: receiver.account, status: foreign_status) }
    let(:mail) { described_class.mention(receiver.account, Notification.create!(account: receiver.account, activity: mention)) }
    let(:notification) { Notification.create!(account: receiver.account, activity: mention) }
    let(:mail) { prepared_mailer_for(receiver.account).mention }

    include_examples 'localized subject', 'notification_mailer.mention.subject', name: 'bob'



@@ 40,7 41,8 @@ RSpec.describe NotificationMailer do

  describe 'follow' do
    let(:follow) { sender.follow!(receiver.account) }
    let(:mail) { described_class.follow(receiver.account, Notification.create!(account: receiver.account, activity: follow)) }
    let(:notification) { Notification.create!(account: receiver.account, activity: follow) }
    let(:mail) { prepared_mailer_for(receiver.account).follow }

    include_examples 'localized subject', 'notification_mailer.follow.subject', name: 'bob'



@@ 56,7 58,8 @@ RSpec.describe NotificationMailer do

  describe 'favourite' do
    let(:favourite) { Favourite.create!(account: sender, status: own_status) }
    let(:mail) { described_class.favourite(own_status.account, Notification.create!(account: receiver.account, activity: favourite)) }
    let(:notification) { Notification.create!(account: receiver.account, activity: favourite) }
    let(:mail) { prepared_mailer_for(own_status.account).favourite }

    include_examples 'localized subject', 'notification_mailer.favourite.subject', name: 'bob'



@@ 73,7 76,8 @@ RSpec.describe NotificationMailer do

  describe 'reblog' do
    let(:reblog) { Status.create!(account: sender, reblog: own_status) }
    let(:mail) { described_class.reblog(own_status.account, Notification.create!(account: receiver.account, activity: reblog)) }
    let(:notification) { Notification.create!(account: receiver.account, activity: reblog) }
    let(:mail) { prepared_mailer_for(own_status.account).reblog }

    include_examples 'localized subject', 'notification_mailer.reblog.subject', name: 'bob'



@@ 90,7 94,8 @@ RSpec.describe NotificationMailer do

  describe 'follow_request' do
    let(:follow_request) { Fabricate(:follow_request, account: sender, target_account: receiver.account) }
    let(:mail) { described_class.follow_request(receiver.account, Notification.create!(account: receiver.account, activity: follow_request)) }
    let(:notification) { Notification.create!(account: receiver.account, activity: follow_request) }
    let(:mail) { prepared_mailer_for(receiver.account).follow_request }

    include_examples 'localized subject', 'notification_mailer.follow_request.subject', name: 'bob'



@@ 103,4 108,10 @@ RSpec.describe NotificationMailer do
      expect(mail.body.encoded).to match('bob has requested to follow you')
    end
  end

  private

  def prepared_mailer_for(recipient)
    described_class.with(recipient: recipient, notification: notification)
  end
end

M spec/mailers/previews/notification_mailer_preview.rb => spec/mailers/previews/notification_mailer_preview.rb +19 -10
@@ 5,31 5,40 @@
class NotificationMailerPreview < ActionMailer::Preview
  # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/mention
  def mention
    m = Mention.last
    NotificationMailer.mention(m.account, Notification.find_by(activity: m))
    activity = Mention.last
    mailer_for(activity.account, activity).mention
  end

  # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/follow
  def follow
    f = Follow.last
    NotificationMailer.follow(f.target_account, Notification.find_by(activity: f))
    activity = Follow.last
    mailer_for(activity.target_account, activity).follow
  end

  # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/follow_request
  def follow_request
    f = Follow.last
    NotificationMailer.follow_request(f.target_account, Notification.find_by(activity: f))
    activity = Follow.last
    mailer_for(activity.target_account, activity).follow_request
  end

  # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/favourite
  def favourite
    f = Favourite.last
    NotificationMailer.favourite(f.status.account, Notification.find_by(activity: f))
    activity = Favourite.last
    mailer_for(activity.status.account, activity).favourite
  end

  # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/reblog
  def reblog
    r = Status.where.not(reblog_of_id: nil).first
    NotificationMailer.reblog(r.reblog.account, Notification.find_by(activity: r))
    activity = Status.where.not(reblog_of_id: nil).first
    mailer_for(activity.reblog.account, activity).reblog
  end

  private

  def mailer_for(account, activity)
    NotificationMailer.with(
      recipient: account,
      notification: Notification.find_by(activity: activity)
    )
  end
end

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

M spec/services/search_service_spec.rb => spec/services/search_service_spec.rb +1 -10
@@ 68,7 68,7 @@ describe SearchService, type: :service do
          allow(AccountSearchService).to receive(:new).and_return(service)

          results = subject.call(query, nil, 10)
          expect(service).to have_received(:call).with(query, nil, limit: 10, offset: 0, resolve: false, use_searchable_text: true, following: false)
          expect(service).to have_received(:call).with(query, nil, limit: 10, offset: 0, resolve: false, start_with_hashtag: false, use_searchable_text: true, following: false)
          expect(results).to eq empty_results.merge(accounts: [account])
        end
      end


@@ 92,15 92,6 @@ describe SearchService, type: :service do
          expect(Tag).to_not have_received(:search_for)
          expect(results).to eq empty_results
        end

        it 'does not include account when starts with # character' do
          query = '#tag'
          allow(AccountSearchService).to receive(:new)

          results = subject.call(query, nil, 10)
          expect(AccountSearchService).to_not have_received(:new)
          expect(results).to eq empty_results
        end
      end
    end
  end