--- /dev/null
+# frozen_string_literal: true
+
+class Api::V1::Accounts::NotesController < Api::BaseController
+ include Authorization
+
+ before_action -> { doorkeeper_authorize! :write, :'write:accounts' }
+ before_action :require_user!
+ before_action :set_account
+
+ def create
+ if params[:comment].blank?
+ AccountNote.find_by(account: current_account, target_account: @account)&.destroy
+ else
+ @note = AccountNote.find_or_initialize_by(account: current_account, target_account: @account)
+ @note.comment = params[:comment]
+ @note.save! if @note.changed?
+ end
+ render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
+ end
+
+ private
+
+ def set_account
+ @account = Account.find(params[:account_id])
+ end
+
+ def relationships_presenter
+ AccountRelationshipsPresenter.new([@account.id], current_user.account_id)
+ end
+end
--- /dev/null
+import api from '../api';
+
+export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST';
+export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS';
+export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL';
+
+export const ACCOUNT_NOTE_INIT_EDIT = 'ACCOUNT_NOTE_INIT_EDIT';
+export const ACCOUNT_NOTE_CANCEL = 'ACCOUNT_NOTE_CANCEL';
+
+export const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT';
+
+export function submitAccountNote() {
+ return (dispatch, getState) => {
+ dispatch(submitAccountNoteRequest());
+
+ const id = getState().getIn(['account_notes', 'edit', 'account_id']);
+
+ api(getState).post(`/api/v1/accounts/${id}/note`, {
+ comment: getState().getIn(['account_notes', 'edit', 'comment']),
+ }).then(response => {
+ dispatch(submitAccountNoteSuccess(response.data));
+ }).catch(error => dispatch(submitAccountNoteFail(error)));
+ };
+};
+
+export function submitAccountNoteRequest() {
+ return {
+ type: ACCOUNT_NOTE_SUBMIT_REQUEST,
+ };
+};
+
+export function submitAccountNoteSuccess(relationship) {
+ return {
+ type: ACCOUNT_NOTE_SUBMIT_SUCCESS,
+ relationship,
+ };
+};
+
+export function submitAccountNoteFail(error) {
+ return {
+ type: ACCOUNT_NOTE_SUBMIT_FAIL,
+ error,
+ };
+};
+
+export function initEditAccountNote(account) {
+ return (dispatch, getState) => {
+ const comment = getState().getIn(['relationships', account.get('id'), 'note']);
+
+ dispatch({
+ type: ACCOUNT_NOTE_INIT_EDIT,
+ account,
+ comment,
+ });
+ };
+};
+
+export function cancelAccountNote() {
+ return {
+ type: ACCOUNT_NOTE_CANCEL,
+ };
+};
+
+export function changeAccountNoteComment(comment) {
+ return {
+ type: ACCOUNT_NOTE_CHANGE_COMMENT,
+ comment,
+ };
+};
--- /dev/null
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Icon from 'mastodon/components/icon';
+import Textarea from 'react-textarea-autosize';
+
+const messages = defineMessages({
+ placeholder: { id: 'account_note.placeholder', defaultMessage: 'No comment provided' },
+});
+
+export default @injectIntl
+class Header extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ isEditing: PropTypes.bool,
+ isSubmitting: PropTypes.bool,
+ accountNote: PropTypes.string,
+ onEditAccountNote: PropTypes.func.isRequired,
+ onCancelAccountNote: PropTypes.func.isRequired,
+ onSaveAccountNote: PropTypes.func.isRequired,
+ onChangeAccountNote: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleChangeAccountNote = (e) => {
+ this.props.onChangeAccountNote(e.target.value);
+ };
+
+ componentWillUnmount () {
+ if (this.props.isEditing) {
+ this.props.onCancelAccountNote();
+ }
+ }
+
+ handleKeyDown = e => {
+ if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+ this.props.onSaveAccountNote();
+ } else if (e.keyCode === 27) {
+ this.props.onCancelAccountNote();
+ }
+ }
+
+ render () {
+ const { account, accountNote, isEditing, isSubmitting, intl } = this.props;
+
+ if (!account || (!accountNote && !isEditing)) {
+ return null;
+ }
+
+ let action_buttons = null;
+ if (isEditing) {
+ action_buttons = (
+ <div className='account__header__account-note__buttons'>
+ <button className='text-btn' tabIndex='0' onClick={this.props.onCancelAccountNote} disabled={isSubmitting}>
+ <Icon id='times' size={15} /> <FormattedMessage id='account_note.cancel' defaultMessage='Cancel' />
+ </button>
+ <div className='flex-spacer' />
+ <button className='text-btn' tabIndex='0' onClick={this.props.onSaveAccountNote} disabled={isSubmitting}>
+ <Icon id='check' size={15} /> <FormattedMessage id='account_note.save' defaultMessage='Save' />
+ </button>
+ </div>
+ );
+ }
+
+ let note_container = null;
+ if (isEditing) {
+ note_container = (
+ <Textarea
+ className='account__header__account-note__content'
+ disabled={isSubmitting}
+ placeholder={intl.formatMessage(messages.placeholder)}
+ value={accountNote}
+ onChange={this.handleChangeAccountNote}
+ onKeyDown={this.handleKeyDown}
+ autoFocus
+ />
+ );
+ } else {
+ note_container = (<div className='account__header__account-note__content'>{accountNote}</div>);
+ }
+
+ return (
+ <div className='account__header__account-note'>
+ <div className='account__header__account-note__header'>
+ <strong><FormattedMessage id='account.account_note_header' defaultMessage='Your note for @{name}' values={{ name: account.get('username') }} /></strong>
+ {!isEditing && (
+ <div>
+ <button className='text-btn' tabIndex='0' onClick={this.props.onEditAccountNote} disabled={isSubmitting}>
+ <Icon id='pencil' size={15} /> <FormattedMessage id='account_note.edit' defaultMessage='Edit' />
+ </button>
+ </div>
+ )}
+ </div>
+ {note_container}
+ {action_buttons}
+ </div>
+ );
+ }
+
+}
import { shortNumberFormat } from 'mastodon/utils/numbers';
import { NavLink } from 'react-router-dom';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
+import AccountNoteContainer from '../containers/account_note_container';
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
+ add_account_note: { id: 'account.add_account_note', defaultMessage: 'Add note for @{name}' },
});
const dateFormatOptions = {
identity_props: ImmutablePropTypes.list,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
+ onEditAccountNote: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
domain: PropTypes.string.isRequired,
};
return null;
}
+ const accountNote = account.getIn(['relationship', 'note']);
+
let info = [];
let actionBtn = '';
let lockedIcon = '';
menu.push(null);
}
+ if (accountNote === null) {
+ menu.push({ text: intl.formatMessage(messages.add_account_note, { name: account.get('username') }), action: this.props.onEditAccountNote });
+ }
+
if (account.get('id') === me) {
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
</h1>
</div>
+ <AccountNoteContainer account={account} />
+
<div className='account__header__extra'>
<div className='account__header__bio'>
{ (fields.size > 0 || identity_proofs.size > 0) && (
--- /dev/null
+import { connect } from 'react-redux';
+import { changeAccountNoteComment, submitAccountNote, initEditAccountNote, cancelAccountNote } from 'mastodon/actions/account_notes';
+import AccountNote from '../components/account_note';
+
+const mapStateToProps = (state, { account }) => {
+ const isEditing = state.getIn(['account_notes', 'edit', 'account_id']) === account.get('id');
+
+ return {
+ isSubmitting: state.getIn(['account_notes', 'edit', 'isSubmitting']),
+ accountNote: isEditing ? state.getIn(['account_notes', 'edit', 'comment']) : account.getIn(['relationship', 'note']),
+ isEditing,
+ };
+};
+
+const mapDispatchToProps = (dispatch, { account }) => ({
+
+ onEditAccountNote() {
+ dispatch(initEditAccountNote(account));
+ },
+
+ onSaveAccountNote() {
+ dispatch(submitAccountNote());
+ },
+
+ onCancelAccountNote() {
+ dispatch(cancelAccountNote());
+ },
+
+ onChangeAccountNote(comment) {
+ dispatch(changeAccountNoteComment(comment));
+ },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);
onUnblockDomain: PropTypes.func.isRequired,
onEndorseToggle: PropTypes.func.isRequired,
onAddToList: PropTypes.func.isRequired,
+ onEditAccountNote: PropTypes.func.isRequired,
hideTabs: PropTypes.bool,
domain: PropTypes.string.isRequired,
};
this.props.onAddToList(this.props.account);
}
+ handleEditAccountNote = () => {
+ this.props.onEditAccountNote(this.props.account);
+ }
+
render () {
const { account, hideTabs, identity_proofs } = this.props;
onUnblockDomain={this.handleUnblockDomain}
onEndorseToggle={this.handleEndorseToggle}
onAddToList={this.handleAddToList}
+ onEditAccountNote={this.handleEditAccountNote}
domain={this.props.domain}
/>
import { initReport } from '../../../actions/reports';
import { openModal } from '../../../actions/modal';
import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
+import { initEditAccountNote } from 'mastodon/actions/account_notes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { unfollowModal } from '../../../initial_state';
import { List as ImmutableList } from 'immutable';
}
},
+ onEditAccountNote (account) {
+ dispatch(initEditAccountNote(account));
+ },
+
onBlockDomain (domain) {
dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />,
--- /dev/null
+import { Map as ImmutableMap } from 'immutable';
+
+import {
+ ACCOUNT_NOTE_INIT_EDIT,
+ ACCOUNT_NOTE_CANCEL,
+ ACCOUNT_NOTE_CHANGE_COMMENT,
+ ACCOUNT_NOTE_SUBMIT_REQUEST,
+ ACCOUNT_NOTE_SUBMIT_FAIL,
+ ACCOUNT_NOTE_SUBMIT_SUCCESS,
+} from '../actions/account_notes';
+
+const initialState = ImmutableMap({
+ edit: ImmutableMap({
+ isSubmitting: false,
+ account_id: null,
+ comment: null,
+ }),
+});
+
+export default function account_notes(state = initialState, action) {
+ switch (action.type) {
+ case ACCOUNT_NOTE_INIT_EDIT:
+ return state.withMutations((state) => {
+ state.setIn(['edit', 'isSubmitting'], false);
+ state.setIn(['edit', 'account_id'], action.account.get('id'));
+ state.setIn(['edit', 'comment'], action.comment);
+ });
+ case ACCOUNT_NOTE_CHANGE_COMMENT:
+ return state.setIn(['edit', 'comment'], action.comment);
+ case ACCOUNT_NOTE_SUBMIT_REQUEST:
+ return state.setIn(['edit', 'isSubmitting'], true);
+ case ACCOUNT_NOTE_SUBMIT_FAIL:
+ return state.setIn(['edit', 'isSubmitting'], false);
+ case ACCOUNT_NOTE_SUBMIT_SUCCESS:
+ case ACCOUNT_NOTE_CANCEL:
+ return state.withMutations((state) => {
+ state.setIn(['edit', 'isSubmitting'], false);
+ state.setIn(['edit', 'account_id'], null);
+ state.setIn(['edit', 'comment'], null);
+ });
+ default:
+ return state;
+ }
+}
import missed_updates from './missed_updates';
import announcements from './announcements';
import markers from './markers';
+import account_notes from './account_notes';
const reducers = {
announcements,
trends,
missed_updates,
markers,
+ account_notes,
};
export default combineReducers(reducers);
DOMAIN_BLOCK_SUCCESS,
DOMAIN_UNBLOCK_SUCCESS,
} from '../actions/domain_blocks';
+import {
+ ACCOUNT_NOTE_SUBMIT_SUCCESS,
+} from '../actions/account_notes';
import { Map as ImmutableMap, fromJS } from 'immutable';
const normalizeRelationship = (state, relationship) => state.set(relationship.id, fromJS(relationship));
case ACCOUNT_UNMUTE_SUCCESS:
case ACCOUNT_PIN_SUCCESS:
case ACCOUNT_UNPIN_SUCCESS:
+ case ACCOUNT_NOTE_SUBMIT_SUCCESS:
return normalizeRelationship(state, action.relationship);
case RELATIONSHIPS_FETCH_SUCCESS:
return normalizeRelationships(state, action.relationships);
color: $primary-text-color;
margin-bottom: 4px;
display: block;
- vertical-align: top;
background-color: $base-overlay-background;
text-transform: uppercase;
font-size: 11px;
}
}
}
+
+ &__account-note {
+ margin: 5px;
+ padding: 10px;
+ background: $ui-highlight-color;
+ color: $primary-text-color;
+ display: flex;
+ flex-direction: column;
+ border-radius: 4px;
+ font-size: 14px;
+ font-weight: 400;
+
+ &__header {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ }
+
+ &__content {
+ white-space: pre-wrap;
+ margin-top: 5px;
+ }
+
+ &__buttons {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end;
+ margin-top: 5px;
+
+ .flex-spacer {
+ flex: 0 0 20px;
+ background: transparent;
+ }
+ }
+
+ strong {
+ font-size: 15px;
+ font-weight: 500;
+ }
+
+ button:hover span {
+ text-decoration: underline;
+ }
+
+ textarea {
+ display: block;
+ box-sizing: border-box;
+ width: 100%;
+ margin: 0;
+ margin-top: 5px;
+ color: $inverted-text-color;
+ background: $simple-background-color;
+ padding: 10px;
+ font-family: inherit;
+ font-size: 14px;
+ resize: none;
+ border: 0;
+ outline: 0;
+ border-radius: 4px;
+ }
+ }
}
.trends {
--- /dev/null
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: account_notes
+#
+# id :bigint(8) not null, primary key
+# account_id :bigint(8)
+# target_account_id :bigint(8)
+# comment :text not null
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+class AccountNote < ApplicationRecord
+ include RelationshipCacheable
+
+ belongs_to :account
+ belongs_to :target_account, class_name: 'Account'
+
+ validates :account_id, uniqueness: { scope: :target_account_id }
+end
follow_mapping(AccountPin.where(account_id: account_id, target_account_id: target_account_ids), :target_account_id)
end
+ def account_note_map(target_account_ids, account_id)
+ AccountNote.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |note, mapping|
+ mapping[note.target_account_id] = {
+ comment: note.comment,
+ }
+ end
+ end
+
def domain_blocking_map(target_account_ids, account_id)
accounts_map = Account.where(id: target_account_ids).select('id, domain').each_with_object({}) { |a, h| h[a.id] = a.domain }
blocked_domains = domain_blocking_map_by_domain(accounts_map.values.compact, account_id)
class AccountRelationshipsPresenter
attr_reader :following, :followed_by, :blocking, :blocked_by,
:muting, :requested, :domain_blocking,
- :endorsed
+ :endorsed, :account_note
def initialize(account_ids, current_account_id, **options)
@account_ids = account_ids.map { |a| a.is_a?(Account) ? a.id : a.to_i }
@requested = cached[:requested].merge(Account.requested_map(@uncached_account_ids, @current_account_id))
@domain_blocking = cached[:domain_blocking].merge(Account.domain_blocking_map(@uncached_account_ids, @current_account_id))
@endorsed = cached[:endorsed].merge(Account.endorsed_map(@uncached_account_ids, @current_account_id))
+ @account_note = cached[:account_note].merge(Account.account_note_map(@uncached_account_ids, @current_account_id))
cache_uncached!
@requested.merge!(options[:requested_map] || {})
@domain_blocking.merge!(options[:domain_blocking_map] || {})
@endorsed.merge!(options[:endorsed_map] || {})
+ @account_note.merge!(options[:account_note_map] || {})
end
private
requested: {},
domain_blocking: {},
endorsed: {},
+ account_note: {},
}
@uncached_account_ids = []
requested: { account_id => requested[account_id] },
domain_blocking: { account_id => domain_blocking[account_id] },
endorsed: { account_id => endorsed[account_id] },
+ account_note: { account_id => account_note[account_id] },
}
Rails.cache.write("relationship:#{@current_account_id}:#{account_id}", maps_for_account, expires_in: 1.day)
class REST::RelationshipSerializer < ActiveModel::Serializer
attributes :id, :following, :showing_reblogs, :followed_by, :blocking, :blocked_by,
:muting, :muting_notifications, :requested, :domain_blocking,
- :endorsed
+ :endorsed, :note
def id
object.id.to_s
def endorsed
instance_options[:relationships].endorsed[object.id] || false
end
+
+ def note
+ (instance_options[:relationships].account_note[object.id] || {})[:comment]
+ end
end
else
queue_follow_unfollows!
end
+
+ copy_account_notes!
rescue ActiveRecord::RecordNotFound
true
end
UnfollowFollowWorker.push_bulk(accounts.map(&:id)) { |follower_id| [follower_id, @source_account.id, @target_account.id, bypass_locked] }
end
end
+
+ def copy_account_notes!
+ AccountNote.where(target_account: @source_account).find_each do |note|
+ text = I18n.with_locale(note.account.user.locale || I18n.default_locale) do
+ I18n.t('move_handler.copy_account_note_text', acct: @source_account.acct)
+ end
+
+ new_note = AccountNote.find_by(account: note.account, target_account: @target_account)
+ if new_note.nil?
+ AccountNote.create!(account: note.account, target_account: @target_account, comment: [text, note.comment].join('\n'))
+ else
+ new_note.update!(comment: [text, note.comment, '\n', new_note.comment].join('\n'))
+ end
+ end
+ end
end
redirect: Your current account's profile will be updated with a redirect notice and be excluded from searches
moderation:
title: Moderation
+ move_handler:
+ copy_account_note_text: 'This user moved from %{acct}, here were your previous notes about them:'
notification_mailer:
digest:
action: View all notifications
resource :pin, only: :create, controller: 'accounts/pins'
post :unpin, to: 'accounts/pins#destroy'
+ resource :note, only: :create, controller: 'accounts/notes'
end
resources :lists, only: [:index, :create, :show, :update, :destroy] do
--- /dev/null
+class CreateAccountNotes < ActiveRecord::Migration[5.2]
+ def change
+ create_table :account_notes do |t|
+ t.references :account, foreign_key: { on_delete: :cascade }, index: false
+ t.references :target_account, foreign_key: { to_table: :accounts, on_delete: :cascade }
+ t.text :comment, null: false
+ t.index [:account_id, :target_account_id], unique: true
+
+ t.timestamps
+ end
+ end
+end
+
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2020_06_27_125810) do
+ActiveRecord::Schema.define(version: 2020_06_28_133322) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
t.index ["user_id"], name: "index_user_invite_requests_on_user_id"
end
+ create_table "account_notes", force: :cascade do |t|
+ t.bigint "account_id"
+ t.bigint "target_account_id"
+ t.text "comment", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id", "target_account_id"], name: "index_account_notes_on_account_id_and_target_account_id", unique: true
+ t.index ["target_account_id"], name: "index_account_notes_on_target_account_id"
+ end
+
create_table "users", force: :cascade do |t|
t.string "email", default: "", null: false
t.datetime "created_at", null: false
add_foreign_key "statuses_tags", "tags", name: "fk_3081861e21", on_delete: :cascade
add_foreign_key "tombstones", "accounts", on_delete: :cascade
add_foreign_key "user_invite_requests", "users", on_delete: :cascade
+ add_foreign_key "account_notes", "accounts", column: "target_account_id", on_delete: :cascade
+ add_foreign_key "account_notes", "accounts", on_delete: :cascade
add_foreign_key "users", "accounts", name: "fk_50500f500d", on_delete: :cascade
add_foreign_key "users", "invites", on_delete: :nullify
add_foreign_key "users", "oauth_applications", column: "created_by_application_id", on_delete: :nullify
--- /dev/null
+Fabricator(:account_note) do
+ account
+ target_account { Fabricate(:account) }
+ comment "User note text"
+end
let(:local_follower) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account }
let(:source_account) { Fabricate(:account, protocol: :activitypub, domain: 'example.com') }
let(:target_account) { Fabricate(:account, protocol: :activitypub, domain: 'example.com') }
+ let(:local_user) { Fabricate(:user) }
+ let!(:account_note) { Fabricate(:account_note, account: local_user.account, target_account: source_account) }
subject { described_class.new }
local_follower.follow!(source_account)
end
+ shared_examples 'user note handling' do
+ it 'copies user note' do
+ allow(UnfollowFollowWorker).to receive(:push_bulk)
+ subject.perform(source_account.id, target_account.id)
+ expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(source_account.acct)
+ expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(account_note.comment)
+ end
+
+ it 'merges user notes when needed' do
+ new_account_note = AccountNote.create!(account: account_note.account, target_account: target_account, comment: 'new note prior to move')
+
+ allow(UnfollowFollowWorker).to receive(:push_bulk)
+ subject.perform(source_account.id, target_account.id)
+ expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(source_account.acct)
+ expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(account_note.comment)
+ expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(new_account_note.comment)
+ end
+ end
+
context 'both accounts are distant' do
describe 'perform' do
it 'calls UnfollowFollowWorker' do
subject.perform(source_account.id, target_account.id)
expect(UnfollowFollowWorker).to have_received(:push_bulk).with([local_follower.id])
end
+
+ include_examples 'user note handling'
end
end
subject.perform(source_account.id, target_account.id)
expect(UnfollowFollowWorker).to have_received(:push_bulk).with([local_follower.id])
end
+
+ include_examples 'user note handling'
end
end
expect(local_follower.following?(target_account)).to be true
end
+ include_examples 'user note handling'
+
it 'does not fail when a local user is already following both accounts' do
double_follower = Fabricate(:user, email: 'eve@example.com', account: Fabricate(:account, username: 'eve')).account
double_follower.follow!(source_account)