diff --git a/app/controllers/api/v1/accounts/notes_controller.rb b/app/controllers/api/v1/accounts/notes_controller.rb
new file mode 100644
index 0000000000..032e807d11
--- /dev/null
+++ b/app/controllers/api/v1/accounts/notes_controller.rb
@@ -0,0 +1,30 @@
+# 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
diff --git a/app/javascript/mastodon/actions/account_notes.js b/app/javascript/mastodon/actions/account_notes.js
new file mode 100644
index 0000000000..059ed9e803
--- /dev/null
+++ b/app/javascript/mastodon/actions/account_notes.js
@@ -0,0 +1,69 @@
+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,
+ };
+};
diff --git a/app/javascript/mastodon/features/account/components/account_note.js b/app/javascript/mastodon/features/account/components/account_note.js
new file mode 100644
index 0000000000..832a96a6ae
--- /dev/null
+++ b/app/javascript/mastodon/features/account/components/account_note.js
@@ -0,0 +1,103 @@
+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 = (
+
{ (fields.size > 0 || identity_proofs.size > 0) && (
diff --git a/app/javascript/mastodon/features/account/containers/account_note_container.js b/app/javascript/mastodon/features/account/containers/account_note_container.js
new file mode 100644
index 0000000000..92d470982b
--- /dev/null
+++ b/app/javascript/mastodon/features/account/containers/account_note_container.js
@@ -0,0 +1,34 @@
+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);
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
index 844b8a236a..4e1b27466b 100644
--- a/app/javascript/mastodon/features/account_timeline/components/header.js
+++ b/app/javascript/mastodon/features/account_timeline/components/header.js
@@ -23,6 +23,7 @@ export default class Header extends ImmutablePureComponent {
onUnblockDomain: PropTypes.func.isRequired,
onEndorseToggle: PropTypes.func.isRequired,
onAddToList: PropTypes.func.isRequired,
+ onEditAccountNote: PropTypes.func.isRequired,
hideTabs: PropTypes.bool,
domain: PropTypes.string.isRequired,
};
@@ -83,6 +84,10 @@ export default class Header extends ImmutablePureComponent {
this.props.onAddToList(this.props.account);
}
+ handleEditAccountNote = () => {
+ this.props.onEditAccountNote(this.props.account);
+ }
+
render () {
const { account, hideTabs, identity_proofs } = this.props;
@@ -108,6 +113,7 @@ export default class Header extends ImmutablePureComponent {
onUnblockDomain={this.handleUnblockDomain}
onEndorseToggle={this.handleEndorseToggle}
onAddToList={this.handleAddToList}
+ onEditAccountNote={this.handleEditAccountNote}
domain={this.props.domain}
/>
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
index 8728b48068..e480fb2aa4 100644
--- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
@@ -19,6 +19,7 @@ import { initBlockModal } from '../../../actions/blocks';
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';
@@ -102,6 +103,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
+ onEditAccountNote (account) {
+ dispatch(initEditAccountNote(account));
+ },
+
onBlockDomain (domain) {
dispatch(openModal('CONFIRM', {
message: {domain} }} />,
diff --git a/app/javascript/mastodon/reducers/account_notes.js b/app/javascript/mastodon/reducers/account_notes.js
new file mode 100644
index 0000000000..b1cf2e0aa8
--- /dev/null
+++ b/app/javascript/mastodon/reducers/account_notes.js
@@ -0,0 +1,44 @@
+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;
+ }
+}
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index 3823bb05e0..690349b85f 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -36,6 +36,7 @@ import trends from './trends';
import missed_updates from './missed_updates';
import announcements from './announcements';
import markers from './markers';
+import account_notes from './account_notes';
const reducers = {
announcements,
@@ -75,6 +76,7 @@ const reducers = {
trends,
missed_updates,
markers,
+ account_notes,
};
export default combineReducers(reducers);
diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js
index 8322780de5..1d050cc634 100644
--- a/app/javascript/mastodon/reducers/relationships.js
+++ b/app/javascript/mastodon/reducers/relationships.js
@@ -17,6 +17,9 @@ import {
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));
@@ -57,6 +60,7 @@ export default function relationships(state = initialState, action) {
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);
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index e22f712614..b3f6d5749f 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -3841,7 +3841,6 @@ a.status-card.compact:hover {
color: $primary-text-color;
margin-bottom: 4px;
display: block;
- vertical-align: top;
background-color: $base-overlay-background;
text-transform: uppercase;
font-size: 11px;
@@ -6605,6 +6604,67 @@ noscript {
}
}
}
+
+ &__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 {
diff --git a/app/models/account_note.rb b/app/models/account_note.rb
new file mode 100644
index 0000000000..bf61df923f
--- /dev/null
+++ b/app/models/account_note.rb
@@ -0,0 +1,20 @@
+# 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
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index 32fcb53975..be7211f2ca 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -44,6 +44,14 @@ module AccountInteractions
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)
diff --git a/app/presenters/account_relationships_presenter.rb b/app/presenters/account_relationships_presenter.rb
index 08614b67c2..d662380f69 100644
--- a/app/presenters/account_relationships_presenter.rb
+++ b/app/presenters/account_relationships_presenter.rb
@@ -3,7 +3,7 @@
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 }
@@ -17,6 +17,7 @@ class AccountRelationshipsPresenter
@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!
@@ -28,6 +29,7 @@ class AccountRelationshipsPresenter
@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
@@ -44,6 +46,7 @@ class AccountRelationshipsPresenter
requested: {},
domain_blocking: {},
endorsed: {},
+ account_note: {},
}
@uncached_account_ids = []
@@ -72,6 +75,7 @@ class AccountRelationshipsPresenter
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)
diff --git a/app/serializers/rest/relationship_serializer.rb b/app/serializers/rest/relationship_serializer.rb
index 1a3fd915cb..e295fb8477 100644
--- a/app/serializers/rest/relationship_serializer.rb
+++ b/app/serializers/rest/relationship_serializer.rb
@@ -3,7 +3,7 @@
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
@@ -50,4 +50,8 @@ class REST::RelationshipSerializer < ActiveModel::Serializer
def endorsed
instance_options[:relationships].endorsed[object.id] || false
end
+
+ def note
+ (instance_options[:relationships].account_note[object.id] || {})[:comment]
+ end
end
diff --git a/app/workers/move_worker.rb b/app/workers/move_worker.rb
index 5957302263..4d76461b07 100644
--- a/app/workers/move_worker.rb
+++ b/app/workers/move_worker.rb
@@ -12,6 +12,8 @@ class MoveWorker
else
queue_follow_unfollows!
end
+
+ copy_account_notes!
rescue ActiveRecord::RecordNotFound
true
end
@@ -34,4 +36,19 @@ class MoveWorker
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
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 7fc58643fa..b3906a127a 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -940,6 +940,8 @@ en:
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
diff --git a/config/routes.rb b/config/routes.rb
index 31333999e7..a16f89687f 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -430,6 +430,7 @@ Rails.application.routes.draw do
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
diff --git a/db/migrate/20200628133322_create_account_notes.rb b/db/migrate/20200628133322_create_account_notes.rb
new file mode 100644
index 0000000000..664727e60b
--- /dev/null
+++ b/db/migrate/20200628133322_create_account_notes.rb
@@ -0,0 +1,13 @@
+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
+
diff --git a/db/schema.rb b/db/schema.rb
index 277eccba77..712ba1d2dc 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# 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"
@@ -833,6 +833,16 @@ ActiveRecord::Schema.define(version: 2020_06_27_125810) do
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
@@ -989,6 +999,8 @@ ActiveRecord::Schema.define(version: 2020_06_27_125810) do
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
diff --git a/spec/fabricators/account_note_fabricator.rb b/spec/fabricators/account_note_fabricator.rb
new file mode 100644
index 0000000000..1b061745a3
--- /dev/null
+++ b/spec/fabricators/account_note_fabricator.rb
@@ -0,0 +1,5 @@
+Fabricator(:account_note) do
+ account
+ target_account { Fabricate(:account) }
+ comment "User note text"
+end
diff --git a/spec/workers/move_worker_spec.rb b/spec/workers/move_worker_spec.rb
index b8f4d99007..77b921eaff 100644
--- a/spec/workers/move_worker_spec.rb
+++ b/spec/workers/move_worker_spec.rb
@@ -6,6 +6,8 @@ describe MoveWorker do
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 }
@@ -13,6 +15,25 @@ describe MoveWorker do
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
@@ -20,6 +41,8 @@ describe MoveWorker 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
@@ -32,6 +55,8 @@ describe MoveWorker 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
@@ -45,6 +70,8 @@ describe MoveWorker do
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)