about summary refs log tree commit diff
path: root/app/javascript/mastodon/features
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2020-07-07 01:24:03 +0200
committerGitHub <noreply@github.com>2020-07-07 01:24:03 +0200
commitc3187411c26aa00eb5844e4a7f9889f2cb19867e (patch)
treeaa5b0676119c27d73680c5e7dd0d709ebbaaadde /app/javascript/mastodon/features
parent83fd046107999cc8ce166c997afee74409359002 (diff)
Change design of account notes in web UI (#14208)
* Change design of account notes in web UI

* Fix `for` -> `htmlFor`
Diffstat (limited to 'app/javascript/mastodon/features')
-rw-r--r--app/javascript/mastodon/features/account/components/account_note.js183
-rw-r--r--app/javascript/mastodon/features/account/components/header.js11
-rw-r--r--app/javascript/mastodon/features/account/containers/account_note_container.js29
-rw-r--r--app/javascript/mastodon/features/account_timeline/containers/header_container.js5
4 files changed, 133 insertions, 95 deletions
diff --git a/app/javascript/mastodon/features/account/components/account_note.js b/app/javascript/mastodon/features/account/components/account_note.js
index 832a96a6a..1787ce1ab 100644
--- a/app/javascript/mastodon/features/account/components/account_note.js
+++ b/app/javascript/mastodon/features/account/components/account_note.js
@@ -3,99 +3,166 @@ 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';
+import { is } from 'immutable';
 
 const messages = defineMessages({
-  placeholder: { id: 'account_note.placeholder', defaultMessage: 'No comment provided' },
+  placeholder: { id: 'account_note.placeholder', defaultMessage: 'Click to add a note' },
 });
 
+class InlineAlert extends React.PureComponent {
+
+  static propTypes = {
+    show: PropTypes.bool,
+  };
+
+  state = {
+    mountMessage: false,
+  };
+
+  static TRANSITION_DELAY = 200;
+
+  componentWillReceiveProps (nextProps) {
+    if (!this.props.show && nextProps.show) {
+      this.setState({ mountMessage: true });
+    } else if (this.props.show && !nextProps.show) {
+      setTimeout(() => this.setState({ mountMessage: false }), InlineAlert.TRANSITION_DELAY);
+    }
+  }
+
+  render () {
+    const { show } = this.props;
+    const { mountMessage } = this.state;
+
+    return (
+      <span aria-live='polite' role='status' className='inline-alert' style={{ opacity: show ? 1 : 0 }}>
+        {mountMessage && <FormattedMessage id='generic.saved' defaultMessage='Saved' />}
+      </span>
+    );
+  }
+
+}
+
 export default @injectIntl
-class Header extends ImmutablePureComponent {
+class AccountNote 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,
+    value: PropTypes.string,
+    onSave: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
   };
 
-  handleChangeAccountNote = (e) => {
-    this.props.onChangeAccountNote(e.target.value);
+  state = {
+    value: null,
+    saving: false,
+    saved: false,
   };
 
+  componentWillMount () {
+    this._reset();
+  }
+
+  componentWillReceiveProps (nextProps) {
+    const accountWillChange = !is(this.props.account, nextProps.account);
+    const newState = {};
+
+    if (accountWillChange && this._isDirty()) {
+      this._save(false);
+    }
+
+    if (accountWillChange || nextProps.value === this.state.value) {
+      newState.saving = false;
+    }
+
+    if (this.props.value !== nextProps.value) {
+      newState.value = nextProps.value;
+    }
+
+    this.setState(newState);
+  }
+
   componentWillUnmount () {
-    if (this.props.isEditing) {
-      this.props.onCancelAccountNote();
+    if (this._isDirty()) {
+      this._save(false);
     }
   }
 
+  setTextareaRef = c => {
+    this.textarea = c;
+  }
+
+  handleChange = e => {
+    this.setState({ value: e.target.value, saving: false });
+  };
+
   handleKeyDown = e => {
     if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
-      this.props.onSaveAccountNote();
+      e.preventDefault();
+
+      this._save();
+
+      if (this.textarea) {
+        this.textarea.blur();
+      }
     } else if (e.keyCode === 27) {
-      this.props.onCancelAccountNote();
+      e.preventDefault();
+
+      this._reset(() => {
+        if (this.textarea) {
+          this.textarea.blur();
+        }
+      });
     }
   }
 
+  handleBlur = () => {
+    if (this._isDirty()) {
+      this._save();
+    }
+  }
+
+  _save (showMessage = true) {
+    this.setState({ saving: true }, () => this.props.onSave(this.state.value));
+
+    if (showMessage) {
+      this.setState({ saved: true }, () => setTimeout(() => this.setState({ saved: false }), 2000));
+    }
+  }
+
+  _reset (callback) {
+    this.setState({ value: this.props.value }, callback);
+  }
+
+  _isDirty () {
+    return !this.state.saving && this.props.value !== null && this.state.value !== null && this.state.value !== this.props.value;
+  }
+
   render () {
-    const { account, accountNote, isEditing, isSubmitting, intl } = this.props;
+    const { account, intl } = this.props;
+    const { value, saved } = this.state;
 
-    if (!account || (!accountNote && !isEditing)) {
+    if (!account) {
       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>
-      );
-    }
+    return (
+      <div className='account__header__account-note'>
+        <label htmlFor={`account-note-${account.get('id')}`}>
+          <FormattedMessage id='account.account_note_header' defaultMessage='Note' /> <InlineAlert show={saved} />
+        </label>
 
-    let note_container = null;
-    if (isEditing) {
-      note_container = (
         <Textarea
+          id={`account-note-${account.get('id')}`}
           className='account__header__account-note__content'
-          disabled={isSubmitting}
+          disabled={this.props.value === null || value === null}
           placeholder={intl.formatMessage(messages.placeholder)}
-          value={accountNote}
-          onChange={this.handleChangeAccountNote}
+          value={value || ''}
+          onChange={this.handleChange}
           onKeyDown={this.handleKeyDown}
-          autoFocus
+          onBlur={this.handleBlur}
+          ref={this.setTextareaRef}
         />
-      );
-    } 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>
     );
   }
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index 144f6bd94..b5aca574f 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -67,7 +67,6 @@ 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,
   };
@@ -132,8 +131,6 @@ class Header extends ImmutablePureComponent {
       return null;
     }
 
-    const accountNote = account.getIn(['relationship', 'note']);
-
     let info        = [];
     let actionBtn   = '';
     let lockedIcon  = '';
@@ -184,10 +181,6 @@ 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' });
@@ -294,8 +287,6 @@ 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) && (
@@ -324,6 +315,8 @@ class Header extends ImmutablePureComponent {
                 </div>
               )}
 
+              {account.get('id') !== me && <AccountNoteContainer account={account} />}
+
               {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content' dangerouslySetInnerHTML={content} />}
             </div>
 
diff --git a/app/javascript/mastodon/features/account/containers/account_note_container.js b/app/javascript/mastodon/features/account/containers/account_note_container.js
index 92d470982..969af553a 100644
--- a/app/javascript/mastodon/features/account/containers/account_note_container.js
+++ b/app/javascript/mastodon/features/account/containers/account_note_container.js
@@ -1,34 +1,17 @@
 import { connect } from 'react-redux';
-import { changeAccountNoteComment, submitAccountNote, initEditAccountNote, cancelAccountNote } from 'mastodon/actions/account_notes';
+import { submitAccountNote } 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 mapStateToProps = (state, { account }) => ({
+  value: account.getIn(['relationship', 'note']),
+});
 
 const mapDispatchToProps = (dispatch, { account }) => ({
 
-  onEditAccountNote() {
-    dispatch(initEditAccountNote(account));
-  },
-
-  onSaveAccountNote() {
-    dispatch(submitAccountNote());
+  onSave (value) {
+    dispatch(submitAccountNote(account.get('id'), value));
   },
 
-  onCancelAccountNote() {
-    dispatch(cancelAccountNote());
-  },
-
-  onChangeAccountNote(comment) {
-    dispatch(changeAccountNoteComment(comment));
-  },
 });
 
 export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
index e480fb2aa..8728b4806 100644
--- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
@@ -19,7 +19,6 @@ 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';
@@ -103,10 +102,6 @@ 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> }} />,