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”
This commit is contained in:
parent
ce9ae9aa50
commit
65506bac3f
22 changed files with 485 additions and 4 deletions
30
app/controllers/api/v1/accounts/notes_controller.rb
Normal file
30
app/controllers/api/v1/accounts/notes_controller.rb
Normal file
|
@ -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
|
69
app/javascript/mastodon/actions/account_notes.js
Normal file
69
app/javascript/mastodon/actions/account_notes.js
Normal file
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -11,6 +11,7 @@ import Avatar from 'mastodon/components/avatar';
|
||||||
import { shortNumberFormat } from 'mastodon/utils/numbers';
|
import { shortNumberFormat } from 'mastodon/utils/numbers';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||||
|
import AccountNoteContainer from '../containers/account_note_container';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||||
|
@ -45,6 +46,7 @@ const messages = defineMessages({
|
||||||
unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
|
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' },
|
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}' },
|
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 = {
|
const dateFormatOptions = {
|
||||||
|
@ -64,6 +66,7 @@ class Header extends ImmutablePureComponent {
|
||||||
identity_props: ImmutablePropTypes.list,
|
identity_props: ImmutablePropTypes.list,
|
||||||
onFollow: PropTypes.func.isRequired,
|
onFollow: PropTypes.func.isRequired,
|
||||||
onBlock: PropTypes.func.isRequired,
|
onBlock: PropTypes.func.isRequired,
|
||||||
|
onEditAccountNote: PropTypes.func.isRequired,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
domain: PropTypes.string.isRequired,
|
domain: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
@ -128,6 +131,8 @@ class Header extends ImmutablePureComponent {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accountNote = account.getIn(['relationship', 'note']);
|
||||||
|
|
||||||
let info = [];
|
let info = [];
|
||||||
let actionBtn = '';
|
let actionBtn = '';
|
||||||
let lockedIcon = '';
|
let lockedIcon = '';
|
||||||
|
@ -178,6 +183,10 @@ class Header extends ImmutablePureComponent {
|
||||||
menu.push(null);
|
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) {
|
if (account.get('id') === me) {
|
||||||
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
|
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
|
||||||
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
|
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
|
||||||
|
@ -284,6 +293,8 @@ class Header extends ImmutablePureComponent {
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AccountNoteContainer account={account} />
|
||||||
|
|
||||||
<div className='account__header__extra'>
|
<div className='account__header__extra'>
|
||||||
<div className='account__header__bio'>
|
<div className='account__header__bio'>
|
||||||
{ (fields.size > 0 || identity_proofs.size > 0) && (
|
{ (fields.size > 0 || identity_proofs.size > 0) && (
|
||||||
|
|
|
@ -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);
|
|
@ -23,6 +23,7 @@ export default class Header extends ImmutablePureComponent {
|
||||||
onUnblockDomain: PropTypes.func.isRequired,
|
onUnblockDomain: PropTypes.func.isRequired,
|
||||||
onEndorseToggle: PropTypes.func.isRequired,
|
onEndorseToggle: PropTypes.func.isRequired,
|
||||||
onAddToList: PropTypes.func.isRequired,
|
onAddToList: PropTypes.func.isRequired,
|
||||||
|
onEditAccountNote: PropTypes.func.isRequired,
|
||||||
hideTabs: PropTypes.bool,
|
hideTabs: PropTypes.bool,
|
||||||
domain: PropTypes.string.isRequired,
|
domain: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
@ -83,6 +84,10 @@ export default class Header extends ImmutablePureComponent {
|
||||||
this.props.onAddToList(this.props.account);
|
this.props.onAddToList(this.props.account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleEditAccountNote = () => {
|
||||||
|
this.props.onEditAccountNote(this.props.account);
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { account, hideTabs, identity_proofs } = this.props;
|
const { account, hideTabs, identity_proofs } = this.props;
|
||||||
|
|
||||||
|
@ -108,6 +113,7 @@ export default class Header extends ImmutablePureComponent {
|
||||||
onUnblockDomain={this.handleUnblockDomain}
|
onUnblockDomain={this.handleUnblockDomain}
|
||||||
onEndorseToggle={this.handleEndorseToggle}
|
onEndorseToggle={this.handleEndorseToggle}
|
||||||
onAddToList={this.handleAddToList}
|
onAddToList={this.handleAddToList}
|
||||||
|
onEditAccountNote={this.handleEditAccountNote}
|
||||||
domain={this.props.domain}
|
domain={this.props.domain}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,7 @@ import { initBlockModal } from '../../../actions/blocks';
|
||||||
import { initReport } from '../../../actions/reports';
|
import { initReport } from '../../../actions/reports';
|
||||||
import { openModal } from '../../../actions/modal';
|
import { openModal } from '../../../actions/modal';
|
||||||
import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
|
import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
|
||||||
|
import { initEditAccountNote } from 'mastodon/actions/account_notes';
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import { unfollowModal } from '../../../initial_state';
|
import { unfollowModal } from '../../../initial_state';
|
||||||
import { List as ImmutableList } from 'immutable';
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
@ -102,6 +103,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onEditAccountNote (account) {
|
||||||
|
dispatch(initEditAccountNote(account));
|
||||||
|
},
|
||||||
|
|
||||||
onBlockDomain (domain) {
|
onBlockDomain (domain) {
|
||||||
dispatch(openModal('CONFIRM', {
|
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> }} />,
|
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> }} />,
|
||||||
|
|
44
app/javascript/mastodon/reducers/account_notes.js
Normal file
44
app/javascript/mastodon/reducers/account_notes.js
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -36,6 +36,7 @@ import trends from './trends';
|
||||||
import missed_updates from './missed_updates';
|
import missed_updates from './missed_updates';
|
||||||
import announcements from './announcements';
|
import announcements from './announcements';
|
||||||
import markers from './markers';
|
import markers from './markers';
|
||||||
|
import account_notes from './account_notes';
|
||||||
|
|
||||||
const reducers = {
|
const reducers = {
|
||||||
announcements,
|
announcements,
|
||||||
|
@ -75,6 +76,7 @@ const reducers = {
|
||||||
trends,
|
trends,
|
||||||
missed_updates,
|
missed_updates,
|
||||||
markers,
|
markers,
|
||||||
|
account_notes,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default combineReducers(reducers);
|
export default combineReducers(reducers);
|
||||||
|
|
|
@ -17,6 +17,9 @@ import {
|
||||||
DOMAIN_BLOCK_SUCCESS,
|
DOMAIN_BLOCK_SUCCESS,
|
||||||
DOMAIN_UNBLOCK_SUCCESS,
|
DOMAIN_UNBLOCK_SUCCESS,
|
||||||
} from '../actions/domain_blocks';
|
} from '../actions/domain_blocks';
|
||||||
|
import {
|
||||||
|
ACCOUNT_NOTE_SUBMIT_SUCCESS,
|
||||||
|
} from '../actions/account_notes';
|
||||||
import { Map as ImmutableMap, fromJS } from 'immutable';
|
import { Map as ImmutableMap, fromJS } from 'immutable';
|
||||||
|
|
||||||
const normalizeRelationship = (state, relationship) => state.set(relationship.id, fromJS(relationship));
|
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_UNMUTE_SUCCESS:
|
||||||
case ACCOUNT_PIN_SUCCESS:
|
case ACCOUNT_PIN_SUCCESS:
|
||||||
case ACCOUNT_UNPIN_SUCCESS:
|
case ACCOUNT_UNPIN_SUCCESS:
|
||||||
|
case ACCOUNT_NOTE_SUBMIT_SUCCESS:
|
||||||
return normalizeRelationship(state, action.relationship);
|
return normalizeRelationship(state, action.relationship);
|
||||||
case RELATIONSHIPS_FETCH_SUCCESS:
|
case RELATIONSHIPS_FETCH_SUCCESS:
|
||||||
return normalizeRelationships(state, action.relationships);
|
return normalizeRelationships(state, action.relationships);
|
||||||
|
|
|
@ -3841,7 +3841,6 @@ a.status-card.compact:hover {
|
||||||
color: $primary-text-color;
|
color: $primary-text-color;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
display: block;
|
display: block;
|
||||||
vertical-align: top;
|
|
||||||
background-color: $base-overlay-background;
|
background-color: $base-overlay-background;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 11px;
|
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 {
|
.trends {
|
||||||
|
|
20
app/models/account_note.rb
Normal file
20
app/models/account_note.rb
Normal file
|
@ -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
|
|
@ -44,6 +44,14 @@ module AccountInteractions
|
||||||
follow_mapping(AccountPin.where(account_id: account_id, target_account_id: target_account_ids), :target_account_id)
|
follow_mapping(AccountPin.where(account_id: account_id, target_account_id: target_account_ids), :target_account_id)
|
||||||
end
|
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)
|
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 }
|
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)
|
blocked_domains = domain_blocking_map_by_domain(accounts_map.values.compact, account_id)
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
class AccountRelationshipsPresenter
|
class AccountRelationshipsPresenter
|
||||||
attr_reader :following, :followed_by, :blocking, :blocked_by,
|
attr_reader :following, :followed_by, :blocking, :blocked_by,
|
||||||
:muting, :requested, :domain_blocking,
|
:muting, :requested, :domain_blocking,
|
||||||
:endorsed
|
:endorsed, :account_note
|
||||||
|
|
||||||
def initialize(account_ids, current_account_id, **options)
|
def initialize(account_ids, current_account_id, **options)
|
||||||
@account_ids = account_ids.map { |a| a.is_a?(Account) ? a.id : a.to_i }
|
@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))
|
@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))
|
@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))
|
@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!
|
cache_uncached!
|
||||||
|
|
||||||
|
@ -28,6 +29,7 @@ class AccountRelationshipsPresenter
|
||||||
@requested.merge!(options[:requested_map] || {})
|
@requested.merge!(options[:requested_map] || {})
|
||||||
@domain_blocking.merge!(options[:domain_blocking_map] || {})
|
@domain_blocking.merge!(options[:domain_blocking_map] || {})
|
||||||
@endorsed.merge!(options[:endorsed_map] || {})
|
@endorsed.merge!(options[:endorsed_map] || {})
|
||||||
|
@account_note.merge!(options[:account_note_map] || {})
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -44,6 +46,7 @@ class AccountRelationshipsPresenter
|
||||||
requested: {},
|
requested: {},
|
||||||
domain_blocking: {},
|
domain_blocking: {},
|
||||||
endorsed: {},
|
endorsed: {},
|
||||||
|
account_note: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
@uncached_account_ids = []
|
@uncached_account_ids = []
|
||||||
|
@ -72,6 +75,7 @@ class AccountRelationshipsPresenter
|
||||||
requested: { account_id => requested[account_id] },
|
requested: { account_id => requested[account_id] },
|
||||||
domain_blocking: { account_id => domain_blocking[account_id] },
|
domain_blocking: { account_id => domain_blocking[account_id] },
|
||||||
endorsed: { account_id => endorsed[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)
|
Rails.cache.write("relationship:#{@current_account_id}:#{account_id}", maps_for_account, expires_in: 1.day)
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
class REST::RelationshipSerializer < ActiveModel::Serializer
|
class REST::RelationshipSerializer < ActiveModel::Serializer
|
||||||
attributes :id, :following, :showing_reblogs, :followed_by, :blocking, :blocked_by,
|
attributes :id, :following, :showing_reblogs, :followed_by, :blocking, :blocked_by,
|
||||||
:muting, :muting_notifications, :requested, :domain_blocking,
|
:muting, :muting_notifications, :requested, :domain_blocking,
|
||||||
:endorsed
|
:endorsed, :note
|
||||||
|
|
||||||
def id
|
def id
|
||||||
object.id.to_s
|
object.id.to_s
|
||||||
|
@ -50,4 +50,8 @@ class REST::RelationshipSerializer < ActiveModel::Serializer
|
||||||
def endorsed
|
def endorsed
|
||||||
instance_options[:relationships].endorsed[object.id] || false
|
instance_options[:relationships].endorsed[object.id] || false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def note
|
||||||
|
(instance_options[:relationships].account_note[object.id] || {})[:comment]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -12,6 +12,8 @@ class MoveWorker
|
||||||
else
|
else
|
||||||
queue_follow_unfollows!
|
queue_follow_unfollows!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
copy_account_notes!
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
true
|
true
|
||||||
end
|
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] }
|
UnfollowFollowWorker.push_bulk(accounts.map(&:id)) { |follower_id| [follower_id, @source_account.id, @target_account.id, bypass_locked] }
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
@ -940,6 +940,8 @@ en:
|
||||||
redirect: Your current account's profile will be updated with a redirect notice and be excluded from searches
|
redirect: Your current account's profile will be updated with a redirect notice and be excluded from searches
|
||||||
moderation:
|
moderation:
|
||||||
title: Moderation
|
title: Moderation
|
||||||
|
move_handler:
|
||||||
|
copy_account_note_text: 'This user moved from %{acct}, here were your previous notes about them:'
|
||||||
notification_mailer:
|
notification_mailer:
|
||||||
digest:
|
digest:
|
||||||
action: View all notifications
|
action: View all notifications
|
||||||
|
|
|
@ -430,6 +430,7 @@ Rails.application.routes.draw do
|
||||||
|
|
||||||
resource :pin, only: :create, controller: 'accounts/pins'
|
resource :pin, only: :create, controller: 'accounts/pins'
|
||||||
post :unpin, to: 'accounts/pins#destroy'
|
post :unpin, to: 'accounts/pins#destroy'
|
||||||
|
resource :note, only: :create, controller: 'accounts/notes'
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :lists, only: [:index, :create, :show, :update, :destroy] do
|
resources :lists, only: [:index, :create, :show, :update, :destroy] do
|
||||||
|
|
13
db/migrate/20200628133322_create_account_notes.rb
Normal file
13
db/migrate/20200628133322_create_account_notes.rb
Normal file
|
@ -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
|
||||||
|
|
14
db/schema.rb
14
db/schema.rb
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "plpgsql"
|
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"
|
t.index ["user_id"], name: "index_user_invite_requests_on_user_id"
|
||||||
end
|
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|
|
create_table "users", force: :cascade do |t|
|
||||||
t.string "email", default: "", null: false
|
t.string "email", default: "", null: false
|
||||||
t.datetime "created_at", 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 "statuses_tags", "tags", name: "fk_3081861e21", on_delete: :cascade
|
||||||
add_foreign_key "tombstones", "accounts", on_delete: :cascade
|
add_foreign_key "tombstones", "accounts", on_delete: :cascade
|
||||||
add_foreign_key "user_invite_requests", "users", 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", "accounts", name: "fk_50500f500d", on_delete: :cascade
|
||||||
add_foreign_key "users", "invites", on_delete: :nullify
|
add_foreign_key "users", "invites", on_delete: :nullify
|
||||||
add_foreign_key "users", "oauth_applications", column: "created_by_application_id", on_delete: :nullify
|
add_foreign_key "users", "oauth_applications", column: "created_by_application_id", on_delete: :nullify
|
||||||
|
|
5
spec/fabricators/account_note_fabricator.rb
Normal file
5
spec/fabricators/account_note_fabricator.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
Fabricator(:account_note) do
|
||||||
|
account
|
||||||
|
target_account { Fabricate(:account) }
|
||||||
|
comment "User note text"
|
||||||
|
end
|
|
@ -6,6 +6,8 @@ describe MoveWorker do
|
||||||
let(:local_follower) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account }
|
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(:source_account) { Fabricate(:account, protocol: :activitypub, domain: 'example.com') }
|
||||||
let(:target_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 }
|
subject { described_class.new }
|
||||||
|
|
||||||
|
@ -13,6 +15,25 @@ describe MoveWorker do
|
||||||
local_follower.follow!(source_account)
|
local_follower.follow!(source_account)
|
||||||
end
|
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
|
context 'both accounts are distant' do
|
||||||
describe 'perform' do
|
describe 'perform' do
|
||||||
it 'calls UnfollowFollowWorker' do
|
it 'calls UnfollowFollowWorker' do
|
||||||
|
@ -20,6 +41,8 @@ describe MoveWorker do
|
||||||
subject.perform(source_account.id, target_account.id)
|
subject.perform(source_account.id, target_account.id)
|
||||||
expect(UnfollowFollowWorker).to have_received(:push_bulk).with([local_follower.id])
|
expect(UnfollowFollowWorker).to have_received(:push_bulk).with([local_follower.id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
include_examples 'user note handling'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -32,6 +55,8 @@ describe MoveWorker do
|
||||||
subject.perform(source_account.id, target_account.id)
|
subject.perform(source_account.id, target_account.id)
|
||||||
expect(UnfollowFollowWorker).to have_received(:push_bulk).with([local_follower.id])
|
expect(UnfollowFollowWorker).to have_received(:push_bulk).with([local_follower.id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
include_examples 'user note handling'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -45,6 +70,8 @@ describe MoveWorker do
|
||||||
expect(local_follower.following?(target_account)).to be true
|
expect(local_follower.following?(target_account)).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
include_examples 'user note handling'
|
||||||
|
|
||||||
it 'does not fail when a local user is already following both accounts' do
|
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 = Fabricate(:user, email: 'eve@example.com', account: Fabricate(:account, username: 'eve')).account
|
||||||
double_follower.follow!(source_account)
|
double_follower.follow!(source_account)
|
||||||
|
|
Loading…
Reference in a new issue