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
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 { 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) && (
|
||||
|
|
|
@ -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,
|
||||
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}
|
||||
/>
|
||||
|
||||
|
|
|
@ -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> }} />,
|
||||
|
|
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 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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue