Skip to content

Commit f126574

Browse files
ClearlyClaireMage
authored andcommitted
Add user notes on accounts (mastodon#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”
1 parent b2425d6 commit f126574

22 files changed

Lines changed: 485 additions & 4 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# frozen_string_literal: true
2+
3+
class Api::V1::Accounts::NotesController < Api::BaseController
4+
include Authorization
5+
6+
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }
7+
before_action :require_user!
8+
before_action :set_account
9+
10+
def create
11+
if params[:comment].blank?
12+
AccountNote.find_by(account: current_account, target_account: @account)&.destroy
13+
else
14+
@note = AccountNote.find_or_initialize_by(account: current_account, target_account: @account)
15+
@note.comment = params[:comment]
16+
@note.save! if @note.changed?
17+
end
18+
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
19+
end
20+
21+
private
22+
23+
def set_account
24+
@account = Account.find(params[:account_id])
25+
end
26+
27+
def relationships_presenter
28+
AccountRelationshipsPresenter.new([@account.id], current_user.account_id)
29+
end
30+
end
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import api from '../api';
2+
3+
export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST';
4+
export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS';
5+
export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL';
6+
7+
export const ACCOUNT_NOTE_INIT_EDIT = 'ACCOUNT_NOTE_INIT_EDIT';
8+
export const ACCOUNT_NOTE_CANCEL = 'ACCOUNT_NOTE_CANCEL';
9+
10+
export const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT';
11+
12+
export function submitAccountNote() {
13+
return (dispatch, getState) => {
14+
dispatch(submitAccountNoteRequest());
15+
16+
const id = getState().getIn(['account_notes', 'edit', 'account_id']);
17+
18+
api(getState).post(`/api/v1/accounts/${id}/note`, {
19+
comment: getState().getIn(['account_notes', 'edit', 'comment']),
20+
}).then(response => {
21+
dispatch(submitAccountNoteSuccess(response.data));
22+
}).catch(error => dispatch(submitAccountNoteFail(error)));
23+
};
24+
};
25+
26+
export function submitAccountNoteRequest() {
27+
return {
28+
type: ACCOUNT_NOTE_SUBMIT_REQUEST,
29+
};
30+
};
31+
32+
export function submitAccountNoteSuccess(relationship) {
33+
return {
34+
type: ACCOUNT_NOTE_SUBMIT_SUCCESS,
35+
relationship,
36+
};
37+
};
38+
39+
export function submitAccountNoteFail(error) {
40+
return {
41+
type: ACCOUNT_NOTE_SUBMIT_FAIL,
42+
error,
43+
};
44+
};
45+
46+
export function initEditAccountNote(account) {
47+
return (dispatch, getState) => {
48+
const comment = getState().getIn(['relationships', account.get('id'), 'note']);
49+
50+
dispatch({
51+
type: ACCOUNT_NOTE_INIT_EDIT,
52+
account,
53+
comment,
54+
});
55+
};
56+
};
57+
58+
export function cancelAccountNote() {
59+
return {
60+
type: ACCOUNT_NOTE_CANCEL,
61+
};
62+
};
63+
64+
export function changeAccountNoteComment(comment) {
65+
return {
66+
type: ACCOUNT_NOTE_CHANGE_COMMENT,
67+
comment,
68+
};
69+
};
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import React from 'react';
2+
import ImmutablePropTypes from 'react-immutable-proptypes';
3+
import PropTypes from 'prop-types';
4+
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
5+
import ImmutablePureComponent from 'react-immutable-pure-component';
6+
import Icon from 'mastodon/components/icon';
7+
import Textarea from 'react-textarea-autosize';
8+
9+
const messages = defineMessages({
10+
placeholder: { id: 'account_note.placeholder', defaultMessage: 'No comment provided' },
11+
});
12+
13+
export default @injectIntl
14+
class Header extends ImmutablePureComponent {
15+
16+
static propTypes = {
17+
account: ImmutablePropTypes.map.isRequired,
18+
isEditing: PropTypes.bool,
19+
isSubmitting: PropTypes.bool,
20+
accountNote: PropTypes.string,
21+
onEditAccountNote: PropTypes.func.isRequired,
22+
onCancelAccountNote: PropTypes.func.isRequired,
23+
onSaveAccountNote: PropTypes.func.isRequired,
24+
onChangeAccountNote: PropTypes.func.isRequired,
25+
intl: PropTypes.object.isRequired,
26+
};
27+
28+
handleChangeAccountNote = (e) => {
29+
this.props.onChangeAccountNote(e.target.value);
30+
};
31+
32+
componentWillUnmount () {
33+
if (this.props.isEditing) {
34+
this.props.onCancelAccountNote();
35+
}
36+
}
37+
38+
handleKeyDown = e => {
39+
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
40+
this.props.onSaveAccountNote();
41+
} else if (e.keyCode === 27) {
42+
this.props.onCancelAccountNote();
43+
}
44+
}
45+
46+
render () {
47+
const { account, accountNote, isEditing, isSubmitting, intl } = this.props;
48+
49+
if (!account || (!accountNote && !isEditing)) {
50+
return null;
51+
}
52+
53+
let action_buttons = null;
54+
if (isEditing) {
55+
action_buttons = (
56+
<div className='account__header__account-note__buttons'>
57+
<button className='text-btn' tabIndex='0' onClick={this.props.onCancelAccountNote} disabled={isSubmitting}>
58+
<Icon id='times' size={15} /> <FormattedMessage id='account_note.cancel' defaultMessage='Cancel' />
59+
</button>
60+
<div className='flex-spacer' />
61+
<button className='text-btn' tabIndex='0' onClick={this.props.onSaveAccountNote} disabled={isSubmitting}>
62+
<Icon id='check' size={15} /> <FormattedMessage id='account_note.save' defaultMessage='Save' />
63+
</button>
64+
</div>
65+
);
66+
}
67+
68+
let note_container = null;
69+
if (isEditing) {
70+
note_container = (
71+
<Textarea
72+
className='account__header__account-note__content'
73+
disabled={isSubmitting}
74+
placeholder={intl.formatMessage(messages.placeholder)}
75+
value={accountNote}
76+
onChange={this.handleChangeAccountNote}
77+
onKeyDown={this.handleKeyDown}
78+
autoFocus
79+
/>
80+
);
81+
} else {
82+
note_container = (<div className='account__header__account-note__content'>{accountNote}</div>);
83+
}
84+
85+
return (
86+
<div className='account__header__account-note'>
87+
<div className='account__header__account-note__header'>
88+
<strong><FormattedMessage id='account.account_note_header' defaultMessage='Your note for @{name}' values={{ name: account.get('username') }} /></strong>
89+
{!isEditing && (
90+
<div>
91+
<button className='text-btn' tabIndex='0' onClick={this.props.onEditAccountNote} disabled={isSubmitting}>
92+
<Icon id='pencil' size={15} /> <FormattedMessage id='account_note.edit' defaultMessage='Edit' />
93+
</button>
94+
</div>
95+
)}
96+
</div>
97+
{note_container}
98+
{action_buttons}
99+
</div>
100+
);
101+
}
102+
103+
}

app/javascript/mastodon/features/account/components/header.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import Avatar from 'mastodon/components/avatar';
1212
import { shortNumberFormat } from 'mastodon/utils/numbers';
1313
import { NavLink } from 'react-router-dom';
1414
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
15+
import AccountNoteContainer from '../containers/account_note_container';
1516
import PawooFollowersYouFollow from 'pawoo/containers/followers_you_follow';
1617

1718
const messages = defineMessages({
@@ -47,6 +48,7 @@ const messages = defineMessages({
4748
unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
4849
add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
4950
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
51+
add_account_note: { id: 'account.add_account_note', defaultMessage: 'Add note for @{name}' },
5052
});
5153

5254
const dateFormatOptions = {
@@ -66,6 +68,7 @@ class Header extends ImmutablePureComponent {
6668
identity_props: ImmutablePropTypes.list,
6769
onFollow: PropTypes.func.isRequired,
6870
onBlock: PropTypes.func.isRequired,
71+
onEditAccountNote: PropTypes.func.isRequired,
6972
intl: PropTypes.object.isRequired,
7073
domain: PropTypes.string.isRequired,
7174
};
@@ -154,6 +157,8 @@ class Header extends ImmutablePureComponent {
154157
return null;
155158
}
156159

160+
const accountNote = account.getIn(['relationship', 'note']);
161+
157162
let info = [];
158163
let actionBtn = '';
159164
let lockedIcon = '';
@@ -204,6 +209,10 @@ class Header extends ImmutablePureComponent {
204209
menu.push(null);
205210
}
206211

212+
if (accountNote === null) {
213+
menu.push({ text: intl.formatMessage(messages.add_account_note, { name: account.get('username') }), action: this.props.onEditAccountNote });
214+
}
215+
207216
if (account.get('id') === me) {
208217
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
209218
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
@@ -312,6 +321,8 @@ class Header extends ImmutablePureComponent {
312321
{this.pawooRenderOauthAthenticationsIcon(account)}
313322
</div>
314323

324+
<AccountNoteContainer account={account} />
325+
315326
<div className='account__header__extra'>
316327
<div className='account__header__bio'>
317328
{ (fields.size > 0 || identity_proofs.size > 0) && (
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { connect } from 'react-redux';
2+
import { changeAccountNoteComment, submitAccountNote, initEditAccountNote, cancelAccountNote } from 'mastodon/actions/account_notes';
3+
import AccountNote from '../components/account_note';
4+
5+
const mapStateToProps = (state, { account }) => {
6+
const isEditing = state.getIn(['account_notes', 'edit', 'account_id']) === account.get('id');
7+
8+
return {
9+
isSubmitting: state.getIn(['account_notes', 'edit', 'isSubmitting']),
10+
accountNote: isEditing ? state.getIn(['account_notes', 'edit', 'comment']) : account.getIn(['relationship', 'note']),
11+
isEditing,
12+
};
13+
};
14+
15+
const mapDispatchToProps = (dispatch, { account }) => ({
16+
17+
onEditAccountNote() {
18+
dispatch(initEditAccountNote(account));
19+
},
20+
21+
onSaveAccountNote() {
22+
dispatch(submitAccountNote());
23+
},
24+
25+
onCancelAccountNote() {
26+
dispatch(cancelAccountNote());
27+
},
28+
29+
onChangeAccountNote(comment) {
30+
dispatch(changeAccountNoteComment(comment));
31+
},
32+
});
33+
34+
export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);

app/javascript/mastodon/features/account_timeline/components/header.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export default class Header extends ImmutablePureComponent {
2323
onUnblockDomain: PropTypes.func.isRequired,
2424
onEndorseToggle: PropTypes.func.isRequired,
2525
onAddToList: PropTypes.func.isRequired,
26+
onEditAccountNote: PropTypes.func.isRequired,
2627
hideTabs: PropTypes.bool,
2728
domain: PropTypes.string.isRequired,
2829
};
@@ -83,6 +84,10 @@ export default class Header extends ImmutablePureComponent {
8384
this.props.onAddToList(this.props.account);
8485
}
8586

87+
handleEditAccountNote = () => {
88+
this.props.onEditAccountNote(this.props.account);
89+
}
90+
8691
render () {
8792
const { account, hideTabs, identity_proofs } = this.props;
8893

@@ -108,6 +113,7 @@ export default class Header extends ImmutablePureComponent {
108113
onUnblockDomain={this.handleUnblockDomain}
109114
onEndorseToggle={this.handleEndorseToggle}
110115
onAddToList={this.handleAddToList}
116+
onEditAccountNote={this.handleEditAccountNote}
111117
domain={this.props.domain}
112118
/>
113119

app/javascript/mastodon/features/account_timeline/containers/header_container.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { initBlockModal } from '../../../actions/blocks';
1919
import { initReport } from '../../../actions/reports';
2020
import { openModal } from '../../../actions/modal';
2121
import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
22+
import { initEditAccountNote } from 'mastodon/actions/account_notes';
2223
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
2324
import { unfollowModal } from '../../../initial_state';
2425
import { List as ImmutableList } from 'immutable';
@@ -102,6 +103,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
102103
}
103104
},
104105

106+
onEditAccountNote (account) {
107+
dispatch(initEditAccountNote(account));
108+
},
109+
105110
onBlockDomain (domain) {
106111
dispatch(openModal('CONFIRM', {
107112
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> }} />,
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Map as ImmutableMap } from 'immutable';
2+
3+
import {
4+
ACCOUNT_NOTE_INIT_EDIT,
5+
ACCOUNT_NOTE_CANCEL,
6+
ACCOUNT_NOTE_CHANGE_COMMENT,
7+
ACCOUNT_NOTE_SUBMIT_REQUEST,
8+
ACCOUNT_NOTE_SUBMIT_FAIL,
9+
ACCOUNT_NOTE_SUBMIT_SUCCESS,
10+
} from '../actions/account_notes';
11+
12+
const initialState = ImmutableMap({
13+
edit: ImmutableMap({
14+
isSubmitting: false,
15+
account_id: null,
16+
comment: null,
17+
}),
18+
});
19+
20+
export default function account_notes(state = initialState, action) {
21+
switch (action.type) {
22+
case ACCOUNT_NOTE_INIT_EDIT:
23+
return state.withMutations((state) => {
24+
state.setIn(['edit', 'isSubmitting'], false);
25+
state.setIn(['edit', 'account_id'], action.account.get('id'));
26+
state.setIn(['edit', 'comment'], action.comment);
27+
});
28+
case ACCOUNT_NOTE_CHANGE_COMMENT:
29+
return state.setIn(['edit', 'comment'], action.comment);
30+
case ACCOUNT_NOTE_SUBMIT_REQUEST:
31+
return state.setIn(['edit', 'isSubmitting'], true);
32+
case ACCOUNT_NOTE_SUBMIT_FAIL:
33+
return state.setIn(['edit', 'isSubmitting'], false);
34+
case ACCOUNT_NOTE_SUBMIT_SUCCESS:
35+
case ACCOUNT_NOTE_CANCEL:
36+
return state.withMutations((state) => {
37+
state.setIn(['edit', 'isSubmitting'], false);
38+
state.setIn(['edit', 'account_id'], null);
39+
state.setIn(['edit', 'comment'], null);
40+
});
41+
default:
42+
return state;
43+
}
44+
}

app/javascript/mastodon/reducers/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import trends from './trends';
3737
import missed_updates from './missed_updates';
3838
import announcements from './announcements';
3939
import markers from './markers';
40+
import account_notes from './account_notes';
4041

4142
const reducers = {
4243
announcements,
@@ -77,6 +78,7 @@ const reducers = {
7778
trends,
7879
missed_updates,
7980
markers,
81+
account_notes,
8082
};
8183

8284
export default combineReducers(reducers);

0 commit comments

Comments
 (0)