diff --git a/app/soapbox/actions/account_notes.js b/app/soapbox/actions/account_notes.js
new file mode 100644
index 000000000..d6aeefc49
--- /dev/null
+++ b/app/soapbox/actions/account_notes.js
@@ -0,0 +1,67 @@
+import api from '../api';
+
+import { openModal, closeModal } from './modals';
+
+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_MODAL = 'ACCOUNT_NOTE_INIT_MODAL';
+
+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(closeModal());
+ 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 initAccountNoteModal(account) {
+ return (dispatch, getState) => {
+ const comment = getState().getIn(['relationships', account.get('id'), 'note']);
+
+ dispatch({
+ type: ACCOUNT_NOTE_INIT_MODAL,
+ account,
+ comment,
+ });
+
+ dispatch(openModal('ACCOUNT_NOTE'));
+ };
+}
+
+export function changeAccountNoteComment(comment) {
+ return {
+ type: ACCOUNT_NOTE_CHANGE_COMMENT,
+ comment,
+ };
+}
\ No newline at end of file
diff --git a/app/soapbox/features/account/components/header.js b/app/soapbox/features/account/components/header.js
index 884d57034..d639838aa 100644
--- a/app/soapbox/features/account/components/header.js
+++ b/app/soapbox/features/account/components/header.js
@@ -62,6 +62,8 @@ const messages = defineMessages({
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
+ createNote: { id: 'account.create_note', defaultMessage: 'Create a note' },
+ editNote: { id: 'account.edit_note', defaultMessage: 'Edit a note' },
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' },
@@ -268,6 +270,14 @@ class Header extends ImmutablePureComponent {
});
}
+ if (features.notes) {
+ menu.push({
+ text: intl.formatMessage(account.getIn(['relationship', 'note']) ? messages.editNote : messages.createNote),
+ action: this.props.onShowNote,
+ icon: require('@tabler/icons/icons/note.svg'),
+ });
+ }
+
if (account.getIn(['relationship', 'following'])) {
if (account.getIn(['relationship', 'showing_reblogs'])) {
menu.push({
diff --git a/app/soapbox/features/account_timeline/components/header.js b/app/soapbox/features/account_timeline/components/header.js
index 51120dfcf..588697943 100644
--- a/app/soapbox/features/account_timeline/components/header.js
+++ b/app/soapbox/features/account_timeline/components/header.js
@@ -131,6 +131,10 @@ export default class Header extends ImmutablePureComponent {
this.props.onUnsuggestUser(this.props.account);
}
+ handleShowNote = () => {
+ this.props.onShowNote(this.props.account);
+ }
+
render() {
const { account, identity_proofs } = this.props;
const moved = (account) ? account.get('moved') : false;
@@ -165,6 +169,7 @@ export default class Header extends ImmutablePureComponent {
onDemoteToUser={this.handleDemoteToUser}
onSuggestUser={this.handleSuggestUser}
onUnsuggestUser={this.handleUnsuggestUser}
+ onShowNote={this.handleShowNote}
username={this.props.username}
/>
diff --git a/app/soapbox/features/account_timeline/containers/header_container.js b/app/soapbox/features/account_timeline/containers/header_container.js
index cec3c29fe..6087217ca 100644
--- a/app/soapbox/features/account_timeline/containers/header_container.js
+++ b/app/soapbox/features/account_timeline/containers/header_container.js
@@ -3,21 +3,7 @@ import React from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
-import {
- verifyUser,
- unverifyUser,
- promoteToAdmin,
- promoteToModerator,
- demoteToUser,
- suggestUsers,
- unsuggestUsers,
-} from 'soapbox/actions/admin';
-import { launchChat } from 'soapbox/actions/chats';
-import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation';
-import { getSettings } from 'soapbox/actions/settings';
-import snackbar from 'soapbox/actions/snackbar';
-import { isAdmin } from 'soapbox/utils/accounts';
-
+import { initAccountNoteModal } from 'soapbox/actions/account_notes';
import {
followAccount,
unfollowAccount,
@@ -28,16 +14,31 @@ import {
unpinAccount,
subscribeAccount,
unsubscribeAccount,
-} from '../../../actions/accounts';
+} from 'soapbox/actions/accounts';
+import {
+ verifyUser,
+ unverifyUser,
+ promoteToAdmin,
+ promoteToModerator,
+ demoteToUser,
+ suggestUsers,
+ unsuggestUsers,
+} from 'soapbox/actions/admin';
+import { launchChat } from 'soapbox/actions/chats';
import {
mentionCompose,
directCompose,
-} from '../../../actions/compose';
-import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
-import { openModal } from '../../../actions/modals';
-import { initMuteModal } from '../../../actions/mutes';
-import { initReport } from '../../../actions/reports';
-import { makeGetAccount } from '../../../selectors';
+} from 'soapbox/actions/compose';
+import { blockDomain, unblockDomain } from 'soapbox/actions/domain_blocks';
+import { openModal } from 'soapbox/actions/modals';
+import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation';
+import { initMuteModal } from 'soapbox/actions/mutes';
+import { initReport } from 'soapbox/actions/reports';
+import { getSettings } from 'soapbox/actions/settings';
+import snackbar from 'soapbox/actions/snackbar';
+import { makeGetAccount } from 'soapbox/selectors';
+import { isAdmin } from 'soapbox/utils/accounts';
+
import Header from '../components/header';
const messages = defineMessages({
@@ -246,6 +247,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
.then(() => dispatch(snackbar.success(message)))
.catch(() => {});
},
+
+ onShowNote(account) {
+ dispatch(initAccountNoteModal(account));
+ },
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));
diff --git a/app/soapbox/features/ui/components/account_note_modal.js b/app/soapbox/features/ui/components/account_note_modal.js
new file mode 100644
index 000000000..4fa61f438
--- /dev/null
+++ b/app/soapbox/features/ui/components/account_note_modal.js
@@ -0,0 +1,109 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+
+import { changeAccountNoteComment, submitAccountNote } from 'soapbox/actions/account_notes';
+import { closeModal } from 'soapbox/actions/modals';
+import Button from 'soapbox/components/button';
+import Icon from 'soapbox/components/icon';
+import { makeGetAccount } from 'soapbox/selectors';
+
+
+const messages = defineMessages({
+ close: { id: 'lightbox.close', defaultMessage: 'Close' },
+ placeholder: { id: 'account_note.placeholder', defaultMessage: 'No comment provided' },
+ save: { id: 'account_note.save', defaultMessage: 'Save' },
+});
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = state => ({
+ isSubmitting: state.getIn(['account_notes', 'edit', 'isSubmitting']),
+ account: getAccount(state, state.getIn(['account_notes', 'edit', 'account_id'])),
+ comment: state.getIn(['account_notes', 'edit', 'comment']),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ onConfirm() {
+ dispatch(submitAccountNote());
+ },
+
+ onClose() {
+ dispatch(closeModal());
+ },
+
+ onCommentChange(comment) {
+ dispatch(changeAccountNoteComment(comment));
+ },
+ };
+};
+
+export default @connect(makeMapStateToProps, mapDispatchToProps)
+@injectIntl
+class AccountNoteModal extends React.PureComponent {
+
+ static propTypes = {
+ isSubmitting: PropTypes.bool,
+ account: PropTypes.object.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onConfirm: PropTypes.func.isRequired,
+ onCommentChange: PropTypes.func.isRequired,
+ comment: PropTypes.string,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleCommentChange = e => {
+ this.props.onCommentChange(e.target.value);
+ }
+
+ handleSubmit = () => {
+ this.props.onConfirm();
+ }
+
+ handleKeyDown = e => {
+ if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+ this.handleSubmit();
+ }
+ }
+
+
+ render() {
+ const { account, isSubmitting, comment, onClose, intl } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
\ No newline at end of file
diff --git a/app/soapbox/features/ui/components/modal_root.js b/app/soapbox/features/ui/components/modal_root.js
index 5b7e43a8a..2ef13cb03 100644
--- a/app/soapbox/features/ui/components/modal_root.js
+++ b/app/soapbox/features/ui/components/modal_root.js
@@ -27,6 +27,7 @@ import {
ReblogsModal,
MentionsModal,
BirthdaysModal,
+ AccountNoteModal,
} from '../../../features/ui/util/async-components';
import BundleContainer from '../containers/bundle_container';
@@ -59,6 +60,7 @@ const MODAL_COMPONENTS = {
'REACTIONS': ReactionsModal,
'MENTIONS': MentionsModal,
'BIRTHDAYS': BirthdaysModal,
+ 'ACCOUNT_NOTE': AccountNoteModal,
};
export default class ModalRoot extends React.PureComponent {
diff --git a/app/soapbox/features/ui/components/profile_info_panel.js b/app/soapbox/features/ui/components/profile_info_panel.js
index 8cd4dfb5d..35d12a627 100644
--- a/app/soapbox/features/ui/components/profile_info_panel.js
+++ b/app/soapbox/features/ui/components/profile_info_panel.js
@@ -9,6 +9,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
+import { initAccountNoteModal } from 'soapbox/actions/account_notes';
import Badge from 'soapbox/components/badge';
import Icon from 'soapbox/components/icon';
import VerificationBadge from 'soapbox/components/verification_badge';
@@ -48,6 +49,7 @@ class ProfileInfoPanel extends ImmutablePureComponent {
intl: PropTypes.object.isRequired,
username: PropTypes.string,
displayFqn: PropTypes.bool,
+ onShowNote: PropTypes.func,
};
getStaffBadge = () => {
@@ -115,6 +117,13 @@ class ProfileInfoPanel extends ImmutablePureComponent {
);
}
+ handleShowNote = e => {
+ const { account, onShowNote } = this.props;
+
+ e.preventDefault();
+ onShowNote(account);
+ }
+
render() {
const { account, displayFqn, intl, identity_proofs, username } = this.props;
@@ -187,6 +196,13 @@ class ProfileInfoPanel extends ImmutablePureComponent {
{this.getBirthday()}
+ {!!account.getIn(['relationship', 'note']) && (
+
+
+
+
+ )}
+
{
};
};
+const mapDispatchToProps = (dispatch) => ({
+ onShowNote(account) {
+ dispatch(initAccountNoteModal(account));
+ },
+});
+
export default injectIntl(
- connect(mapStateToProps, null, null, {
+ connect(mapStateToProps, mapDispatchToProps, null, {
forwardRef: true,
},
)(ProfileInfoPanel));
diff --git a/app/soapbox/features/ui/util/async-components.js b/app/soapbox/features/ui/util/async-components.js
index 5f44a2801..9c2218f8e 100644
--- a/app/soapbox/features/ui/util/async-components.js
+++ b/app/soapbox/features/ui/util/async-components.js
@@ -218,6 +218,10 @@ export function BirthdaysModal() {
return import(/* webpackChunkName: "features/ui" */'../components/birthdays_modal');
}
+export function AccountNoteModal() {
+ return import(/* webpackChunkName: "features/ui" */'../components/account_note_modal');
+}
+
export function ListEditor() {
return import(/* webpackChunkName: "features/list_editor" */'../../list_editor');
}
diff --git a/app/soapbox/reducers/account_notes.js b/app/soapbox/reducers/account_notes.js
new file mode 100644
index 000000000..67cd83e1d
--- /dev/null
+++ b/app/soapbox/reducers/account_notes.js
@@ -0,0 +1,37 @@
+import { Map as ImmutableMap } from 'immutable';
+
+import {
+ ACCOUNT_NOTE_INIT_MODAL,
+ 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: null,
+ comment: null,
+ }),
+});
+
+export default function account_notes(state = initialState, action) {
+ switch (action.type) {
+ case ACCOUNT_NOTE_INIT_MODAL:
+ 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:
+ case ACCOUNT_NOTE_SUBMIT_SUCCESS:
+ return state.setIn(['edit', 'isSubmitting'], false);
+ default:
+ return state;
+ }
+}
\ No newline at end of file
diff --git a/app/soapbox/reducers/index.js b/app/soapbox/reducers/index.js
index a5c178b3c..c7b48cc22 100644
--- a/app/soapbox/reducers/index.js
+++ b/app/soapbox/reducers/index.js
@@ -3,6 +3,7 @@ import { combineReducers } from 'redux-immutable';
import { AUTH_LOGGED_OUT } from 'soapbox/actions/auth';
+import account_notes from './account_notes';
import accounts from './accounts';
import accounts_counters from './accounts_counters';
import accounts_meta from './accounts_meta';
@@ -66,6 +67,7 @@ const appReducer = combineReducers({
user_lists,
domain_lists,
status_lists,
+ account_notes,
accounts,
accounts_counters,
statuses,
diff --git a/app/soapbox/reducers/relationships.js b/app/soapbox/reducers/relationships.js
index 29276c5c3..129040d99 100644
--- a/app/soapbox/reducers/relationships.js
+++ b/app/soapbox/reducers/relationships.js
@@ -3,6 +3,7 @@ import { get } from 'lodash';
import { STREAMING_FOLLOW_RELATIONSHIPS_UPDATE } from 'soapbox/actions/streaming';
+import { ACCOUNT_NOTE_SUBMIT_SUCCESS } from '../actions/account_notes';
import {
ACCOUNT_FOLLOW_SUCCESS,
ACCOUNT_FOLLOW_REQUEST,
@@ -106,6 +107,7 @@ export default function relationships(state = initialState, action) {
case ACCOUNT_UNSUBSCRIBE_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/soapbox/utils/features.js b/app/soapbox/utils/features.js
index 54355e005..0516ef02d 100644
--- a/app/soapbox/utils/features.js
+++ b/app/soapbox/utils/features.js
@@ -89,6 +89,10 @@ export const getFeatures = createSelector([instance => instance], instance => {
birthdays: v.software === PLEROMA && gte(v.version, '2.4.50'),
ethereumLogin: v.software === MITRA,
accountMoving: v.software === PLEROMA && gte(v.version, '2.4.50'),
+ notes: any([
+ v.software === MASTODON && gte(v.compatVersion, '3.2.0'),
+ v.software === PLEROMA && gte(v.version, '2.4.50'),
+ ]),
};
});
diff --git a/app/styles/components/modal.scss b/app/styles/components/modal.scss
index aa12f4092..7d40aab68 100644
--- a/app/styles/components/modal.scss
+++ b/app/styles/components/modal.scss
@@ -126,7 +126,9 @@
top: 0;
bottom: 0;
- @media screen and (max-width: 600px) { padding: 30px 2px; }
+ @media screen and (max-width: 600px) {
+ padding: 30px 2px;
+ }
.svg-icon {
width: 24px;
@@ -342,7 +344,8 @@
.mute-modal,
.reactions-modal,
.reblogs-modal,
-.mentions-modal {
+.mentions-modal,
+.account-note-modal {
position: relative;
flex-direction: column;
overflow: hidden;
@@ -411,7 +414,8 @@
.boost-modal__action-bar,
.confirmation-modal__action-bar,
-.mute-modal__action-bar {
+.mute-modal__action-bar,
+.account-note-modal__action-bar {
display: flex;
align-items: center;
justify-content: space-between;
@@ -464,7 +468,8 @@
vertical-align: middle;
}
-.report-modal {
+.report-modal,
+.account-note-modal {
width: 90vw;
max-width: 700px;
}
@@ -521,27 +526,6 @@
margin-bottom: 20px;
}
- .setting-text {
- display: block;
- box-sizing: border-box;
- width: 100%;
- margin: 0;
- color: var(--primary-text-color);
- background: var(--background-color);
- padding: 10px;
- font-family: inherit;
- font-size: 14px;
- resize: vertical;
- outline: 0;
- border: 1px solid var(--background-color);
- border-radius: 4px;
- margin-bottom: 20px;
-
- &:focus {
- border: 1px solid var(--background-color);
- }
- }
-
.setting-toggle {
margin-top: 20px;
margin-bottom: 24px;
@@ -574,7 +558,9 @@
max-height: 300px;
}
- .actions-modal__item-label { font-weight: 500; }
+ .actions-modal__item-label {
+ font-weight: 500;
+ }
ul {
overflow-y: auto;
@@ -582,12 +568,18 @@
max-height: calc(100vh - 147px);
// NOTE - not sure what this is yet, leaving alone for now until I find out.
- &.with-status { max-height: calc(80vh - 75px); }
+ &.with-status {
+ max-height: calc(80vh - 75px);
+ }
- li:empty { margin: 0; }
+ li:empty {
+ margin: 0;
+ }
li:not(:empty) {
- &:first-of-type { margin: 10px 0 0; }
+ &:first-of-type {
+ margin: 10px 0 0;
+ }
a {
display: flex;
@@ -654,10 +646,12 @@
}
.confirmation-modal__action-bar,
-.mute-modal__action-bar {
+.mute-modal__action-bar,
+.account-note-modal__action-bar {
.confirmation-modal__secondary-button,
.confirmation-modal__cancel-button,
- .mute-modal__cancel-button {
+ .mute-modal__cancel-button,
+ .account-note-modal__cancel-button {
background-color: transparent;
color: var(--highlight-text-color);
font-size: 14px;
@@ -726,7 +720,9 @@
}
.modal-layout {
- background: var(--brand-color--med) url('data:image/svg+xml;utf8,') repeat-x bottom fixed;
+ background: var(--brand-color--med)
+ url('data:image/svg+xml;utf8,')
+ repeat-x bottom fixed;
display: flex;
flex-direction: column;
height: 100vh;
@@ -1068,7 +1064,8 @@
}
.confirmation-modal,
-.mute-modal {
+.mute-modal,
+.account-note-modal {
&__header {
display: flex;
align-items: center;
@@ -1090,3 +1087,35 @@
text-align: left;
}
}
+
+.report-modal__comment,
+.account-note-modal__container {
+ .setting-text {
+ display: block;
+ box-sizing: border-box;
+ width: 100%;
+ margin: 0;
+ color: var(--primary-text-color);
+ background: var(--background-color);
+ padding: 10px;
+ font-family: inherit;
+ font-size: 14px;
+ resize: vertical;
+ outline: 0;
+ border: 1px solid var(--background-color);
+ border-radius: 4px;
+ margin-bottom: 20px;
+
+ &:focus {
+ border: 1px solid var(--background-color);
+ }
+ }
+}
+
+.account-note-modal {
+ .setting-text {
+ margin-top: 20px;
+ margin-bottom: 0;
+ resize: none;
+ }
+}
diff --git a/app/styles/components/profile-info-panel.scss b/app/styles/components/profile-info-panel.scss
index a3c3f6dc5..65e70c4bd 100644
--- a/app/styles/components/profile-info-panel.scss
+++ b/app/styles/components/profile-info-panel.scss
@@ -23,7 +23,8 @@
}
&__join-date,
- &__birthday {
+ &__birthday,
+ &__note {
display: flex;
font-size: 14px;
color: var(--primary-text-color--faint);
@@ -39,6 +40,15 @@
}
}
+ &__note {
+ text-decoration: none;
+
+ &:hover,
+ &:focus {
+ text-decoration: underline;
+ }
+ }
+
&__stats {
margin: 15px 0;
@@ -163,10 +173,10 @@
}
.profile-info-panel__name-content::before {
- content: '[';
+ content: "[";
}
.profile-info-panel__name-content::after {
- content: ']';
+ content: "]";
}
}