diff options
author | ThibG <thib@sitedethib.com> | 2020-06-30 19:19:50 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-06-30 19:19:50 +0200 |
commit | 65506bac3f3fe233b5b7b3241020bd74eb5c9259 (patch) | |
tree | 54b590e3d00fee936792d68f2932fa0d807ab980 | |
parent | ce9ae9aa50cab4de4db5c06f54f4f95d736d68c6 (diff) |
Add user notes on accounts (#14148)
* Add UserNote model * Add UI for user notes * Put comment in relationships entity * Add API to create user notes * Copy user notes to new account when receiving a Move activity * Address some of the review remarks * Replace modal by inline edition * Please CodeClimate * Button design changes * Change design again * Cancel note edition when pressing Escape * Fixes * Tweak design again * Move “Add note” item, and allow users to add notes to themselves * Rename UserNote into AccountNote, rename “comment” Relationship attribute to “note”
22 files changed, 485 insertions, 4 deletions
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 000000000..032e807d1 --- /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 000000000..059ed9e80 --- /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 000000000..832a96a6a --- /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 = ( + <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> + ); + } + +} diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index 8c85bbc39..eca0b7901 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -11,6 +11,7 @@ import Avatar from 'mastodon/components/avatar'; 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' }, @@ -45,6 +46,7 @@ const messages = defineMessages({ 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 = { @@ -64,6 +66,7 @@ class Header extends ImmutablePureComponent { identity_props: ImmutablePropTypes.list, onFollow: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired, + onEditAccountNote: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, domain: PropTypes.string.isRequired, }; @@ -128,6 +131,8 @@ class Header extends ImmutablePureComponent { return null; } + const accountNote = account.getIn(['relationship', 'note']); + let info = []; let actionBtn = ''; let lockedIcon = ''; @@ -178,6 +183,10 @@ class Header extends ImmutablePureComponent { 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' }); @@ -284,6 +293,8 @@ class Header extends ImmutablePureComponent { </h1> </div> + <AccountNoteContainer account={account} /> + <div className='account__header__extra'> <div className='account__header__bio'> { (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 000000000..92d470982 --- /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 844b8a236..4e1b27466 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 8728b4806..e480fb2aa 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: <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> }} />, diff --git a/app/javascript/mastodon/reducers/account_notes.js b/app/javascript/mastodon/reducers/account_notes.js new file mode 100644 index 000000000..b1cf2e0aa --- /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 3823bb05e..690349b85 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 8322780de..1d050cc63 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 e22f71261..b3f6d5749 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 000000000..bf61df923 --- /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 32fcb5397..be7211f2c 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 08614b67c..d662380f6 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 1a3fd915c..e295fb847 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 595730226..4d76461b0 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 7fc58643f..b3906a127 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 31333999e..a16f89687 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 000000000..664727e60 --- /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 277eccba7..712ba1d2d 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 000000000..1b061745a --- /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 b8f4d9900..77b921eaf 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) |