about summary refs log tree commit diff
path: root/app/javascript
diff options
context:
space:
mode:
authorThibaut Girka <thib@sitedethib.com>2020-06-30 23:00:20 +0200
committerThibaut Girka <thib@sitedethib.com>2020-06-30 23:00:20 +0200
commit9c3b9d5ab91874f5bd9b7225c402816676065f00 (patch)
tree4d1389cc00bcc59f85a6d42c1a49b5c1d50f3b3c /app/javascript
parent10ede3eb27c5de5d4e3a283e333af2a54da6177d (diff)
parent65506bac3f3fe233b5b7b3241020bd74eb5c9259 (diff)
Merge branch 'master' into glitch-soc/merge-upstream
Conflicts:
- `.env.production.sample`:
  Upstream deleted it, I decided to keep it.
- `package.json`:
  Upstream updated a dependency textually too close to wavesurfer.js
  which has been deleted from upstream but is kept in glitch-soc for now.
Diffstat (limited to 'app/javascript')
-rw-r--r--app/javascript/mastodon/actions/account_notes.js69
-rw-r--r--app/javascript/mastodon/components/__tests__/button-test.js10
-rw-r--r--app/javascript/mastodon/components/status.js3
-rw-r--r--app/javascript/mastodon/features/account/components/account_note.js103
-rw-r--r--app/javascript/mastodon/features/account/components/header.js11
-rw-r--r--app/javascript/mastodon/features/account/containers/account_note_container.js34
-rw-r--r--app/javascript/mastodon/features/account_timeline/components/header.js6
-rw-r--r--app/javascript/mastodon/features/account_timeline/containers/header_container.js5
-rw-r--r--app/javascript/mastodon/features/audio/index.js42
-rw-r--r--app/javascript/mastodon/features/status/components/card.js8
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js3
-rw-r--r--app/javascript/mastodon/features/ui/components/__tests__/column-test.js15
-rw-r--r--app/javascript/mastodon/reducers/account_notes.js44
-rw-r--r--app/javascript/mastodon/reducers/index.js2
-rw-r--r--app/javascript/mastodon/reducers/markers.js2
-rw-r--r--app/javascript/mastodon/reducers/relationships.js4
-rw-r--r--app/javascript/mastodon/test_setup.js6
-rw-r--r--app/javascript/styles/mastodon/components.scss71
18 files changed, 391 insertions, 47 deletions
diff --git a/app/javascript/mastodon/actions/account_notes.js b/app/javascript/mastodon/actions/account_notes.js
new file mode 100644
index 000000000..059ed9e80
--- /dev/null
+++ b/app/javascript/mastodon/actions/account_notes.js
@@ -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,
+  };
+};
diff --git a/app/javascript/mastodon/components/__tests__/button-test.js b/app/javascript/mastodon/components/__tests__/button-test.js
index 160cd3cbc..f5a649f70 100644
--- a/app/javascript/mastodon/components/__tests__/button-test.js
+++ b/app/javascript/mastodon/components/__tests__/button-test.js
@@ -1,4 +1,4 @@
-import { shallow } from 'enzyme';
+import { render, fireEvent, screen } from '@testing-library/react';
 import React from 'react';
 import renderer from 'react-test-renderer';
 import Button from '../button';
@@ -21,16 +21,16 @@ describe('<Button />', () => {
 
   it('handles click events using the given handler', () => {
     const handler = jest.fn();
-    const button  = shallow(<Button onClick={handler} />);
-    button.find('button').simulate('click');
+    render(<Button onClick={handler}>button</Button>);
+    fireEvent.click(screen.getByText('button'));
 
     expect(handler.mock.calls.length).toEqual(1);
   });
 
   it('does not handle click events if props.disabled given', () => {
     const handler = jest.fn();
-    const button  = shallow(<Button onClick={handler} disabled />);
-    button.find('button').simulate('click');
+    render(<Button onClick={handler} disabled>button</Button>);
+    fireEvent.click(screen.getByText('button'));
 
     expect(handler.mock.calls.length).toEqual(0);
   });
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 2dc961936..827b69500 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -352,7 +352,8 @@ class Status extends ImmutablePureComponent {
               <Component
                 src={attachment.get('url')}
                 alt={attachment.get('description')}
-                poster={status.getIn(['account', 'avatar_static'])}
+                poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
+                blurhash={attachment.get('blurhash')}
                 duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
                 width={this.props.cachedMediaWidth}
                 height={110}
diff --git a/app/javascript/mastodon/features/account/components/account_note.js b/app/javascript/mastodon/features/account/components/account_note.js
new file mode 100644
index 000000000..832a96a6a
--- /dev/null
+++ b/app/javascript/mastodon/features/account/components/account_note.js
@@ -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>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index 8c85bbc39..eca0b7901 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -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) && (
diff --git a/app/javascript/mastodon/features/account/containers/account_note_container.js b/app/javascript/mastodon/features/account/containers/account_note_container.js
new file mode 100644
index 000000000..92d470982
--- /dev/null
+++ b/app/javascript/mastodon/features/account/containers/account_note_container.js
@@ -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);
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
index 844b8a236..4e1b27466 100644
--- a/app/javascript/mastodon/features/account_timeline/components/header.js
+++ b/app/javascript/mastodon/features/account_timeline/components/header.js
@@ -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}
         />
 
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 8728b4806..e480fb2aa 100644
--- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
@@ -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> }} />,
diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js
index 5f6132f12..99926e52a 100644
--- a/app/javascript/mastodon/features/audio/index.js
+++ b/app/javascript/mastodon/features/audio/index.js
@@ -157,6 +157,7 @@ class Audio extends React.PureComponent {
     fullscreen: PropTypes.bool,
     intl: PropTypes.object.isRequired,
     cacheWidth: PropTypes.func,
+    blurhash: PropTypes.string,
   };
 
   state = {
@@ -222,32 +223,42 @@ class Audio extends React.PureComponent {
     window.addEventListener('scroll', this.handleScroll);
     window.addEventListener('resize', this.handleResize, { passive: true });
 
-    const img = new Image();
-    img.crossOrigin = 'anonymous';
-    img.onload = () => this.handlePosterLoad(img);
-    img.src = this.props.poster;
+    if (!this.props.blurhash) {
+      const img = new Image();
+      img.crossOrigin = 'anonymous';
+      img.onload = () => this.handlePosterLoad(img);
+      img.src = this.props.poster;
+    } else {
+      this._setColorScheme();
+      this._decodeBlurhash();
+    }
   }
 
   componentDidUpdate (prevProps, prevState) {
-    if (prevProps.poster !== this.props.poster) {
+    if (prevProps.poster !== this.props.poster && !this.props.blurhash) {
       const img = new Image();
       img.crossOrigin = 'anonymous';
       img.onload = () => this.handlePosterLoad(img);
       img.src = this.props.poster;
     }
 
-    if (prevState.blurhash !== this.state.blurhash) {
-      const context = this.blurhashCanvas.getContext('2d');
-      const pixels = decode(this.state.blurhash, 32, 32);
-      const outputImageData = new ImageData(pixels, 32, 32);
-
-      context.putImageData(outputImageData, 0, 0);
+    if (prevState.blurhash !== this.state.blurhash || prevProps.blurhash !== this.props.blurhash) {
+      this._setColorScheme();
+      this._decodeBlurhash();
     }
 
     this._clear();
     this._draw();
   }
 
+  _decodeBlurhash () {
+    const context = this.blurhashCanvas.getContext('2d');
+    const pixels = decode(this.props.blurhash || this.state.blurhash, 32, 32);
+    const outputImageData = new ImageData(pixels, 32, 32);
+
+    context.putImageData(outputImageData, 0, 0);
+  }
+
   componentWillUnmount () {
     window.removeEventListener('scroll', this.handleScroll);
     window.removeEventListener('resize', this.handleResize);
@@ -415,7 +426,7 @@ class Audio extends React.PureComponent {
   }
 
   handlePosterLoad = image => {
-    const canvas = document.createElement('canvas');
+    const canvas  = document.createElement('canvas');
     const context = canvas.getContext('2d');
 
     canvas.width  = image.width;
@@ -425,10 +436,15 @@ class Audio extends React.PureComponent {
 
     const inputImageData = context.getImageData(0, 0, image.width, image.height);
     const blurhash = encode(inputImageData.data, image.width, image.height, 4, 4);
+
+    this.setState({ blurhash });
+  }
+
+  _setColorScheme () {
+    const blurhash     = this.props.blurhash || this.state.blurhash;
     const averageColor = decodeRGB(decode83(blurhash.slice(2, 6)));
 
     this.setState({
-      blurhash,
       color: adjustColor(averageColor),
       darkText: luma(averageColor) >= 165,
     });
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
index e35b1fd5f..0af7c54e4 100644
--- a/app/javascript/mastodon/features/status/components/card.js
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -6,7 +6,6 @@ import { FormattedMessage } from 'react-intl';
 import punycode from 'punycode';
 import classnames from 'classnames';
 import Icon from 'mastodon/components/icon';
-import classNames from 'classnames';
 import { useBlurhash } from 'mastodon/initial_state';
 import { decode } from 'blurhash';
 import { debounce } from 'lodash';
@@ -231,7 +230,7 @@ export default class Card extends React.PureComponent {
     const height      = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
 
     const description = (
-      <div className={classNames('status-card__content', { 'status-card__content--blurred': !revealed })}>
+      <div className='status-card__content'>
         {title}
         {!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
         <span className='status-card__host'>{provider}</span>
@@ -239,7 +238,7 @@ export default class Card extends React.PureComponent {
     );
 
     let embed     = '';
-    let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />;
+    let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classnames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />;
     let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />;
     let spoilerButton = (
       <button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
@@ -247,7 +246,7 @@ export default class Card extends React.PureComponent {
       </button>
     );
     spoilerButton = (
-      <div className={classNames('spoiler-button', { 'spoiler-button--minified': revealed })}>
+      <div className={classnames('spoiler-button', { 'spoiler-button--minified': revealed })}>
         {spoilerButton}
       </div>
     );
@@ -305,7 +304,6 @@ export default class Card extends React.PureComponent {
       <a href={card.get('url')} className={className} target='_blank' rel='noopener noreferrer' ref={this.setRef}>
         {embed}
         {description}
-        {!revealed && spoilerButton}
       </a>
     );
   }
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index 935e4207e..f7d0c9bd4 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -125,7 +125,8 @@ class DetailedStatus extends ImmutablePureComponent {
             src={attachment.get('url')}
             alt={attachment.get('description')}
             duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
-            poster={status.getIn(['account', 'avatar_static'])}
+            poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
+            blurhash={attachment.get('blurhash')}
             height={150}
           />
         );
diff --git a/app/javascript/mastodon/features/ui/components/__tests__/column-test.js b/app/javascript/mastodon/features/ui/components/__tests__/column-test.js
index d2791ce08..a56859be0 100644
--- a/app/javascript/mastodon/features/ui/components/__tests__/column-test.js
+++ b/app/javascript/mastodon/features/ui/components/__tests__/column-test.js
@@ -1,25 +1,24 @@
+import { render, fireEvent, screen } from '@testing-library/react';
 import React from 'react';
-import { mount } from 'enzyme';
 import Column from '../column';
-import ColumnHeader from '../column_header';
 
 describe('<Column />', () => {
   describe('<ColumnHeader /> click handler', () => {
     it('runs the scroll animation if the column contains scrollable content', () => {
-      const wrapper = mount(
+      const scrollToMock = jest.fn();
+      const { container } = render(
         <Column heading='notifications'>
           <div className='scrollable' />
         </Column>,
       );
-      const scrollToMock = jest.fn();
-      wrapper.find(Column).find('.scrollable').getDOMNode().scrollTo = scrollToMock;
-      wrapper.find(ColumnHeader).find('button').simulate('click');
+      container.querySelector('.scrollable').scrollTo = scrollToMock;
+      fireEvent.click(screen.getByText('notifications'));
       expect(scrollToMock).toHaveBeenCalledWith({ behavior: 'smooth', top: 0 });
     });
 
     it('does not try to scroll if there is no scrollable content', () => {
-      const wrapper = mount(<Column heading='notifications' />);
-      wrapper.find(ColumnHeader).find('button').simulate('click');
+      render(<Column heading='notifications' />);
+      fireEvent.click(screen.getByText('notifications'));
     });
   });
 });
diff --git a/app/javascript/mastodon/reducers/account_notes.js b/app/javascript/mastodon/reducers/account_notes.js
new file mode 100644
index 000000000..b1cf2e0aa
--- /dev/null
+++ b/app/javascript/mastodon/reducers/account_notes.js
@@ -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;
+  }
+}
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index 3823bb05e..690349b85 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -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);
diff --git a/app/javascript/mastodon/reducers/markers.js b/app/javascript/mastodon/reducers/markers.js
index 2e67be82e..fb1572ff5 100644
--- a/app/javascript/mastodon/reducers/markers.js
+++ b/app/javascript/mastodon/reducers/markers.js
@@ -1,6 +1,6 @@
 import {
   MARKERS_SUBMIT_SUCCESS,
-} from '../actions/notifications';
+} from '../actions/markers';
 
 const initialState = ImmutableMap({
   home: '0',
diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js
index 8322780de..1d050cc63 100644
--- a/app/javascript/mastodon/reducers/relationships.js
+++ b/app/javascript/mastodon/reducers/relationships.js
@@ -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);
diff --git a/app/javascript/mastodon/test_setup.js b/app/javascript/mastodon/test_setup.js
index 80148379b..666127af3 100644
--- a/app/javascript/mastodon/test_setup.js
+++ b/app/javascript/mastodon/test_setup.js
@@ -1,5 +1 @@
-import { configure } from 'enzyme';
-import Adapter from 'enzyme-adapter-react-16';
-
-const adapter = new Adapter();
-configure({ adapter });
+import '@testing-library/jest-dom/extend-expect';
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 0a6c20098..0c594ef56 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -3105,11 +3105,6 @@ a.status-card {
   flex: 1 1 auto;
   overflow: hidden;
   padding: 14px 14px 14px 8px;
-
-  &--blurred {
-    filter: blur(2px);
-    pointer-events: none;
-  }
 }
 
 .status-card__description {
@@ -3846,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;
@@ -6502,7 +6496,7 @@ noscript {
   &__tabs {
     display: flex;
     align-items: flex-start;
-    padding: 7px 5px;
+    padding: 7px 10px;
     margin-top: -55px;
 
     &__buttons {
@@ -6524,7 +6518,7 @@ noscript {
     }
 
     &__name {
-      padding: 5px;
+      padding: 5px 10px;
 
       .account-role {
         vertical-align: top;
@@ -6610,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 {