diff options
Diffstat (limited to 'app')
16 files changed, 424 insertions, 3 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 |