Skip to content

Commit

Permalink
Add user notes on accounts (mastodon#14148)
Browse files Browse the repository at this point in the history
* 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”
  • Loading branch information
ClearlyClaire committed Jun 30, 2020
1 parent ce9ae9a commit 65506ba
Show file tree
Hide file tree
Showing 22 changed files with 485 additions and 4 deletions.
30 changes: 30 additions & 0 deletions app/controllers/api/v1/accounts/notes_controller.rb
Original file line number Diff line number Diff line change
@@ -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 changes: 69 additions & 0 deletions app/javascript/mastodon/actions/account_notes.js
Original file line number Diff line number Diff line change
@@ -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,
};
};
103 changes: 103 additions & 0 deletions app/javascript/mastodon/features/account/components/account_note.js
Original file line number Diff line number Diff line change
@@ -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 changes: 11 additions & 0 deletions app/javascript/mastodon/features/account/components/header.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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 = {
Expand All @@ -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,
};
Expand Down Expand Up @@ -128,6 +131,8 @@ class Header extends ImmutablePureComponent {
return null;
}

const accountNote = account.getIn(['relationship', 'note']);

let info = [];
let actionBtn = '';
let lockedIcon = '';
Expand Down Expand Up @@ -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' });
Expand Down Expand Up @@ -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) && (
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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;

Expand All @@ -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}
/>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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> }} />,
Expand Down
44 changes: 44 additions & 0 deletions app/javascript/mastodon/reducers/account_notes.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
2 changes: 2 additions & 0 deletions app/javascript/mastodon/reducers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -75,6 +76,7 @@ const reducers = {
trends,
missed_updates,
markers,
account_notes,
};

export default combineReducers(reducers);

0 comments on commit 65506ba

Please sign in to comment.