diff --git a/app/javascript/mastodon/features/account/components/account_note.jsx b/app/javascript/mastodon/features/account/components/account_note.jsx
deleted file mode 100644
index 855a1f4fc8..0000000000
--- a/app/javascript/mastodon/features/account/components/account_note.jsx
+++ /dev/null
@@ -1,181 +0,0 @@
-import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
-
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-
-import { is } from 'immutable';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-import Textarea from 'react-textarea-autosize';
-
-import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
-
-const messages = defineMessages({
- placeholder: { id: 'account_note.placeholder', defaultMessage: 'Click to add a note' },
-});
-
-class InlineAlert extends PureComponent {
-
- static propTypes = {
- show: PropTypes.bool,
- };
-
- state = {
- mountMessage: false,
- };
-
- static TRANSITION_DELAY = 200;
-
- UNSAFE_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 (
-
- {mountMessage && }
-
- );
- }
-
-}
-
-class AccountNote extends ImmutablePureComponent {
-
- static propTypes = {
- accountId: PropTypes.string.isRequired,
- value: PropTypes.string,
- onSave: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- state = {
- value: null,
- saving: false,
- saved: false,
- };
-
- UNSAFE_componentWillMount () {
- this._reset();
- }
-
- UNSAFE_componentWillReceiveProps (nextProps) {
- const accountWillChange = !is(this.props.accountId, nextProps.accountId);
- 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._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)) {
- e.preventDefault();
-
- if (this.textarea) {
- this.textarea.blur();
- } else {
- this._save();
- }
- } else if (e.keyCode === 27) {
- 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 { accountId, intl } = this.props;
- const { value, saved } = this.state;
-
- if (!accountId) {
- return null;
- }
-
- return (
-
-
-
- {this.props.value === undefined ? (
-
-
-
- ) : (
-
- )}
-
- );
- }
-
-}
-
-export default injectIntl(AccountNote);
diff --git a/app/javascript/mastodon/features/account/components/account_note.tsx b/app/javascript/mastodon/features/account/components/account_note.tsx
new file mode 100644
index 0000000000..ea3a4cdaca
--- /dev/null
+++ b/app/javascript/mastodon/features/account/components/account_note.tsx
@@ -0,0 +1,131 @@
+import type { ChangeEventHandler, KeyboardEventHandler } from 'react';
+import { useState, useRef, useCallback, useId } from 'react';
+
+import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
+
+import Textarea from 'react-textarea-autosize';
+
+import { submitAccountNote } from '@/mastodon/actions/account_notes';
+import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
+import { useAppDispatch, useAppSelector } from '@/mastodon/store';
+
+const messages = defineMessages({
+ placeholder: {
+ id: 'account_note.placeholder',
+ defaultMessage: 'Click to add a note',
+ },
+});
+
+const AccountNoteUI: React.FC<{
+ initialValue: string | undefined;
+ onSubmit: (newNote: string) => void;
+ wasSaved: boolean;
+}> = ({ initialValue, onSubmit, wasSaved }) => {
+ const intl = useIntl();
+ const uniqueId = useId();
+ const [value, setValue] = useState(initialValue ?? '');
+ const isLoading = initialValue === undefined;
+ const canSubmitOnBlurRef = useRef(true);
+
+ const handleChange = useCallback>(
+ (e) => {
+ setValue(e.target.value);
+ },
+ [],
+ );
+
+ const handleKeyDown = useCallback>(
+ (e) => {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+
+ setValue(initialValue ?? '');
+
+ canSubmitOnBlurRef.current = false;
+ e.currentTarget.blur();
+ } else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+
+ onSubmit(value);
+
+ canSubmitOnBlurRef.current = false;
+ e.currentTarget.blur();
+ }
+ },
+ [initialValue, onSubmit, value],
+ );
+
+ const handleBlur = useCallback(() => {
+ if (initialValue !== value && canSubmitOnBlurRef.current) {
+ onSubmit(value);
+ }
+ canSubmitOnBlurRef.current = true;
+ }, [initialValue, onSubmit, value]);
+
+ return (
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export const AccountNote: React.FC<{
+ accountId: string;
+}> = ({ accountId }) => {
+ const dispatch = useAppDispatch();
+ const initialValue = useAppSelector((state) =>
+ state.relationships.get(accountId)?.get('note'),
+ );
+ const [wasSaved, setWasSaved] = useState(false);
+
+ const handleSubmit = useCallback(
+ (note: string) => {
+ setWasSaved(true);
+ void dispatch(submitAccountNote({ accountId, note }));
+
+ setTimeout(() => {
+ setWasSaved(false);
+ }, 2000);
+ },
+ [dispatch, accountId],
+ );
+
+ return (
+
+ );
+};
diff --git a/app/javascript/mastodon/features/account/containers/account_note_container.js b/app/javascript/mastodon/features/account/containers/account_note_container.js
deleted file mode 100644
index 77de964039..0000000000
--- a/app/javascript/mastodon/features/account/containers/account_note_container.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import { connect } from 'react-redux';
-
-import { submitAccountNote } from 'mastodon/actions/account_notes';
-
-import AccountNote from '../components/account_note';
-
-const mapStateToProps = (state, { accountId }) => ({
- value: state.relationships.getIn([accountId, 'note']),
-});
-
-const mapDispatchToProps = (dispatch, { accountId }) => ({
-
- onSave (value) {
- dispatch(submitAccountNote({ accountId: accountId, note: value }));
- },
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);
diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx
index ff70fd0055..a156e0cc36 100644
--- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx
+++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx
@@ -44,8 +44,8 @@ import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { ShortNumber } from 'mastodon/components/short_number';
+import { AccountNote } from 'mastodon/features/account/components/account_note';
import { DomainPill } from 'mastodon/features/account/components/domain_pill';
-import AccountNoteContainer from 'mastodon/features/account/containers/account_note_container';
import FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_note_container';
import { useLinks } from 'mastodon/hooks/useLinks';
import { useIdentity } from 'mastodon/identity_context';
@@ -923,7 +923,7 @@ export const AccountHeader: React.FC<{
onClickCapture={handleLinkClick}
>
{account.id !== me && signedIn && (
-
+
)}
{account.note.length > 0 && account.note !== '' && (