From ebde60ca82318d20628b591ef31ab15aa92edd3a Mon Sep 17 00:00:00 2001
From: Eugen Rochko <eugen@zeonfederated.com>
Date: Tue, 25 Feb 2025 16:30:46 +0100
Subject: [PATCH] Refactor `<Header>` into TypeScript (#33956)

---
 app/javascript/mastodon/actions/accounts.js   |    7 +
 .../mastodon/components/follow_button.tsx     |    2 +-
 .../features/account/components/header.jsx    |  528 --------
 .../features/account_gallery/index.jsx        |    4 +-
 .../components/account_header.tsx             | 1057 +++++++++++++++++
 .../account_timeline/components/header.jsx    |  155 ---
 .../containers/header_container.jsx           |  153 ---
 .../features/account_timeline/index.jsx       |    6 +-
 .../mastodon/features/followers/index.jsx     |    6 +-
 .../mastodon/features/following/index.jsx     |    6 +-
 .../mastodon/models/dropdown_menu.ts          |   24 +
 app/javascript/mastodon/selectors/accounts.ts |   14 +
 app/javascript/mastodon/selectors/index.js    |   18 +-
 13 files changed, 1121 insertions(+), 859 deletions(-)
 delete mode 100644 app/javascript/mastodon/features/account/components/header.jsx
 create mode 100644 app/javascript/mastodon/features/account_timeline/components/account_header.tsx
 delete mode 100644 app/javascript/mastodon/features/account_timeline/components/header.jsx
 delete mode 100644 app/javascript/mastodon/features/account_timeline/containers/header_container.jsx
 create mode 100644 app/javascript/mastodon/models/dropdown_menu.ts

diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js
index 3d0e8b8c90..d821381ce0 100644
--- a/app/javascript/mastodon/actions/accounts.js
+++ b/app/javascript/mastodon/actions/accounts.js
@@ -142,6 +142,13 @@ export function fetchAccountFail(id, error) {
   };
 }
 
+/**
+ * @param {string} id
+ * @param {Object} options
+ * @param {boolean} [options.reblogs]
+ * @param {boolean} [options.notify]
+ * @returns {function(): void}
+ */
 export function followAccount(id, options = { reblogs: true }) {
   return (dispatch, getState) => {
     const alreadyFollowing = getState().getIn(['relationships', id, 'following']);
diff --git a/app/javascript/mastodon/components/follow_button.tsx b/app/javascript/mastodon/components/follow_button.tsx
index a21317e524..f49abfd2b3 100644
--- a/app/javascript/mastodon/components/follow_button.tsx
+++ b/app/javascript/mastodon/components/follow_button.tsx
@@ -55,7 +55,7 @@ export const FollowButton: React.FC<{
       );
     }
 
-    if (!relationship) return;
+    if (!relationship || !accountId) return;
 
     if (accountId === me) {
       return;
diff --git a/app/javascript/mastodon/features/account/components/header.jsx b/app/javascript/mastodon/features/account/components/header.jsx
deleted file mode 100644
index 003845c323..0000000000
--- a/app/javascript/mastodon/features/account/components/header.jsx
+++ /dev/null
@@ -1,528 +0,0 @@
-import PropTypes from 'prop-types';
-
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-
-import classNames from 'classnames';
-import { Helmet } from 'react-helmet';
-import { NavLink, withRouter } from 'react-router-dom';
-
-import { isFulfilled, isRejected } from '@reduxjs/toolkit';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-import CheckIcon from '@/material-icons/400-24px/check.svg?react';
-import LockIcon from '@/material-icons/400-24px/lock.svg?react';
-import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
-import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
-import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react';
-import ShareIcon from '@/material-icons/400-24px/share.svg?react';
-import { Avatar } from 'mastodon/components/avatar';
-import { Badge, AutomatedBadge, GroupBadge } from 'mastodon/components/badge';
-import { Button } from 'mastodon/components/button';
-import { CopyIconButton } from 'mastodon/components/copy_icon_button';
-import { FollowersCounter, FollowingCounter, StatusesCounter } from 'mastodon/components/counters';
-import { Icon }  from 'mastodon/components/icon';
-import { IconButton } from 'mastodon/components/icon_button';
-import { LoadingIndicator } from 'mastodon/components/loading_indicator';
-import { ShortNumber } from 'mastodon/components/short_number';
-import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
-import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
-import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state';
-import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
-import { WithRouterPropTypes } from 'mastodon/utils/react_router';
-
-import AccountNoteContainer from '../containers/account_note_container';
-import FollowRequestNoteContainer from '../containers/follow_request_note_container';
-
-import { DomainPill } from './domain_pill';
-
-const messages = defineMessages({
-  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
-  follow: { id: 'account.follow', defaultMessage: 'Follow' },
-  followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
-  mutual: { id: 'account.mutual', defaultMessage: 'Mutual' },
-  cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
-  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
-  unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
-  edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
-  linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' },
-  account_locked: { id: 'account.locked_info', defaultMessage: 'This account privacy status is set to locked. The owner manually reviews who can follow them.' },
-  mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
-  direct: { id: 'account.direct', defaultMessage: 'Privately mention @{name}' },
-  unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
-  block: { id: 'account.block', defaultMessage: 'Block @{name}' },
-  mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
-  report: { id: 'account.report', defaultMessage: 'Report @{name}' },
-  share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' },
-  copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' },
-  media: { id: 'account.media', defaultMessage: 'Media' },
-  blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
-  unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
-  hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' },
-  showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' },
-  enableNotifications: { id: 'account.enable_notifications', defaultMessage: 'Notify me when @{name} posts' },
-  disableNotifications: { id: 'account.disable_notifications', defaultMessage: 'Stop notifying me when @{name} posts' },
-  pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
-  preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
-  follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
-  favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
-  lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
-  followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
-  blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
-  domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
-  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' },
-  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}' },
-  admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
-  languages: { id: 'account.languages', defaultMessage: 'Change subscribed languages' },
-  openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
-});
-
-const titleFromAccount = account => {
-  const displayName = account.get('display_name');
-  const acct = account.get('acct') === account.get('username') ? `${account.get('username')}@${localDomain}` : account.get('acct');
-  const prefix = displayName.trim().length === 0 ? account.get('username') : displayName;
-
-  return `${prefix} (@${acct})`;
-};
-
-const messageForFollowButton = relationship => {
-  if(!relationship) return messages.follow;
-
-  if (relationship.get('following') && relationship.get('followed_by')) {
-    return messages.mutual;
-  } else if (relationship.get('following') || relationship.get('requested')) {
-    return messages.unfollow;
-  } else if (relationship.get('followed_by')) {
-    return messages.followBack;
-  } else {
-    return messages.follow;
-  }
-};
-
-const dateFormatOptions = {
-  month: 'short',
-  day: 'numeric',
-  year: 'numeric',
-  hour: '2-digit',
-  minute: '2-digit',
-};
-
-class Header extends ImmutablePureComponent {
-
-  static propTypes = {
-    identity: identityContextPropShape,
-    account: ImmutablePropTypes.record,
-    identity_props: ImmutablePropTypes.list,
-    onFollow: PropTypes.func.isRequired,
-    onBlock: PropTypes.func.isRequired,
-    onMention: PropTypes.func.isRequired,
-    onDirect: PropTypes.func.isRequired,
-    onReblogToggle: PropTypes.func.isRequired,
-    onNotifyToggle: PropTypes.func.isRequired,
-    onReport: PropTypes.func.isRequired,
-    onMute: PropTypes.func.isRequired,
-    onBlockDomain: PropTypes.func.isRequired,
-    onUnblockDomain: PropTypes.func.isRequired,
-    onEndorseToggle: PropTypes.func.isRequired,
-    onAddToList: PropTypes.func.isRequired,
-    onEditAccountNote: PropTypes.func.isRequired,
-    onChangeLanguages: PropTypes.func.isRequired,
-    onInteractionModal: PropTypes.func.isRequired,
-    onOpenAvatar: PropTypes.func.isRequired,
-    onOpenURL: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
-    domain: PropTypes.string.isRequired,
-    hidden: PropTypes.bool,
-    ...WithRouterPropTypes,
-  };
-
-  setRef = c => {
-    this.node = c;
-  };
-
-  openEditProfile = () => {
-    window.open('/settings/profile', '_blank');
-  };
-
-  isStatusesPageActive = (match, location) => {
-    if (!match) {
-      return false;
-    }
-
-    return !location.pathname.match(/\/(followers|following)\/?$/);
-  };
-
-  handleMouseEnter = ({ currentTarget }) => {
-    if (autoPlayGif) {
-      return;
-    }
-
-    const emojis = currentTarget.querySelectorAll('.custom-emoji');
-
-    for (var i = 0; i < emojis.length; i++) {
-      let emoji = emojis[i];
-      emoji.src = emoji.getAttribute('data-original');
-    }
-  };
-
-  handleMouseLeave = ({ currentTarget }) => {
-    if (autoPlayGif) {
-      return;
-    }
-
-    const emojis = currentTarget.querySelectorAll('.custom-emoji');
-
-    for (var i = 0; i < emojis.length; i++) {
-      let emoji = emojis[i];
-      emoji.src = emoji.getAttribute('data-static');
-    }
-  };
-
-  handleAvatarClick = e => {
-    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
-      e.preventDefault();
-      this.props.onOpenAvatar();
-    }
-  };
-
-  handleShare = () => {
-    const { account } = this.props;
-
-    navigator.share({
-      url: account.get('url'),
-    }).catch((e) => {
-      if (e.name !== 'AbortError') console.error(e);
-    });
-  };
-
-  handleHashtagClick = e => {
-    const { history } = this.props;
-    const value = e.currentTarget.textContent.replace(/^#/, '');
-
-    if (history && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
-      e.preventDefault();
-      history.push(`/tags/${value}`);
-    }
-  };
-
-  handleMentionClick = e => {
-    const { history, onOpenURL } = this.props;
-
-    if (history && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
-      e.preventDefault();
-
-      const link = e.currentTarget;
-
-      onOpenURL(link.href).then((result) => {
-        if (isFulfilled(result)) {
-          if (result.payload.accounts[0]) {
-            history.push(`/@${result.payload.accounts[0].acct}`);
-          } else if (result.payload.statuses[0]) {
-            history.push(`/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`);
-          } else {
-            window.location = link.href;
-          }
-        } else if (isRejected(result)) {
-          window.location = link.href;
-        }
-      }).catch(() => {
-        // Nothing
-      });
-    }
-  };
-
-  _attachLinkEvents () {
-    const node = this.node;
-
-    if (!node) {
-      return;
-    }
-
-    const links = node.querySelectorAll('a');
-
-    let link;
-
-    for (var i = 0; i < links.length; ++i) {
-      link = links[i];
-
-      if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
-        link.addEventListener('click', this.handleHashtagClick, false);
-      } else if (link.classList.contains('mention')) {
-        link.addEventListener('click', this.handleMentionClick, false);
-      }
-    }
-  }
-
-  componentDidMount () {
-    this._attachLinkEvents();
-  }
-
-  componentDidUpdate () {
-    this._attachLinkEvents();
-  }
-
-  render () {
-    const { account, hidden, intl } = this.props;
-    const { signedIn, permissions } = this.props.identity;
-
-    if (!account) {
-      return null;
-    }
-
-    const suspended    = account.get('suspended');
-    const isRemote     = account.get('acct') !== account.get('username');
-    const remoteDomain = isRemote ? account.get('acct').split('@')[1] : null;
-
-    let actionBtn, bellBtn, lockedIcon, shareBtn;
-
-    let info = [];
-    let menu = [];
-
-    if (me !== account.get('id') && account.getIn(['relationship', 'blocking'])) {
-      info.push(<span key='blocked' className='relationship-tag'><FormattedMessage id='account.blocked' defaultMessage='Blocked' /></span>);
-    }
-
-    if (me !== account.get('id') && account.getIn(['relationship', 'muting'])) {
-      info.push(<span key='muted' className='relationship-tag'><FormattedMessage id='account.muted' defaultMessage='Muted' /></span>);
-    } else if (me !== account.get('id') && account.getIn(['relationship', 'domain_blocking'])) {
-      info.push(<span key='domain_blocked' className='relationship-tag'><FormattedMessage id='account.domain_blocked' defaultMessage='Domain blocked' /></span>);
-    }
-
-    if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
-      bellBtn = <IconButton icon={account.getIn(['relationship', 'notifying']) ? 'bell' : 'bell-o'} iconComponent={account.getIn(['relationship', 'notifying']) ? NotificationsActiveIcon : NotificationsIcon} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
-    }
-
-    if ('share' in navigator) {
-      shareBtn = <IconButton className='optional' iconComponent={ShareIcon} title={intl.formatMessage(messages.share, { name: account.get('username') })} onClick={this.handleShare} />;
-    } else {
-      shareBtn = <CopyIconButton className='optional' title={intl.formatMessage(messages.copy)} value={account.get('url')} />;
-    }
-
-    if (me !== account.get('id')) {
-      if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded
-        actionBtn = <Button disabled><LoadingIndicator /></Button>;
-      } else if (!account.getIn(['relationship', 'blocking'])) {
-        actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames({ 'button--destructive': (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) })} text={intl.formatMessage(messageForFollowButton(account.get('relationship')))} onClick={signedIn ? this.props.onFollow : this.props.onInteractionModal} />;
-      } else if (account.getIn(['relationship', 'blocking'])) {
-        actionBtn = <Button text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />;
-      }
-    } else {
-      actionBtn = <Button text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />;
-    }
-
-    if (account.get('moved') && !account.getIn(['relationship', 'following'])) {
-      actionBtn = '';
-    }
-
-    if (account.get('locked')) {
-      lockedIcon = <Icon id='lock' icon={LockIcon} title={intl.formatMessage(messages.account_locked)} />;
-    }
-
-    if (signedIn && account.get('id') !== me && !account.get('suspended')) {
-      menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
-      menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.props.onDirect });
-      menu.push(null);
-    }
-
-    if (isRemote) {
-      menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: account.get('url') });
-      menu.push(null);
-    }
-
-    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' });
-      menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
-      menu.push(null);
-      menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
-      menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
-      menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
-      menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
-      menu.push(null);
-      menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
-      menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
-      menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
-    } else if (signedIn) {
-      if (account.getIn(['relationship', 'following'])) {
-        if (!account.getIn(['relationship', 'muting'])) {
-          if (account.getIn(['relationship', 'showing_reblogs'])) {
-            menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
-          } else {
-            menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
-          }
-
-          menu.push({ text: intl.formatMessage(messages.languages), action: this.props.onChangeLanguages });
-          menu.push(null);
-        }
-
-        menu.push({ text: intl.formatMessage(account.getIn(['relationship', 'endorsed']) ? messages.unendorse : messages.endorse), action: this.props.onEndorseToggle });
-        menu.push({ text: intl.formatMessage(messages.add_or_remove_from_list), action: this.props.onAddToList });
-        menu.push(null);
-      }
-
-      if (account.getIn(['relationship', 'muting'])) {
-        menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute });
-      } else {
-        menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute, dangerous: true });
-      }
-
-      if (account.getIn(['relationship', 'blocking'])) {
-        menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock });
-      } else {
-        menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock, dangerous: true });
-      }
-
-      if (!account.get('suspended')) {
-        menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport, dangerous: true });
-      }
-    }
-
-    if (signedIn && isRemote) {
-      menu.push(null);
-
-      if (account.getIn(['relationship', 'domain_blocking'])) {
-        menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain: remoteDomain }), action: this.props.onUnblockDomain });
-      } else {
-        menu.push({ text: intl.formatMessage(messages.blockDomain, { domain: remoteDomain }), action: this.props.onBlockDomain, dangerous: true });
-      }
-    }
-
-    if ((account.get('id') !== me && (permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
-      menu.push(null);
-      if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
-        menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${account.get('id')}` });
-      }
-      if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
-        menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: remoteDomain }), href: `/admin/instances/${remoteDomain}` });
-      }
-    }
-
-    const content         = { __html: account.get('note_emojified') };
-    const displayNameHtml = { __html: account.get('display_name_html') };
-    const fields          = account.get('fields');
-    const isLocal         = account.get('acct').indexOf('@') === -1;
-    const username        = account.get('acct').split('@')[0];
-    const domain          = isLocal ? localDomain : account.get('acct').split('@')[1];
-    const isIndexable     = !account.get('noindex');
-
-    const badges = [];
-
-    if (account.get('bot')) {
-      badges.push(<AutomatedBadge key='bot-badge' />);
-    } else if (account.get('group')) {
-      badges.push(<GroupBadge key='group-badge' />);
-    }
-
-    account.get('roles', []).forEach((role) => {
-      badges.push(<Badge key={`role-badge-${role.get('id')}`} label={<span>{role.get('name')}</span>} domain={domain} roleId={role.get('id')} />);
-    });
-
-    return (
-      <div className={classNames('account__header', { inactive: !!account.get('moved') })} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
-        {!(suspended || hidden || account.get('moved')) && account.getIn(['relationship', 'requested_by']) && <FollowRequestNoteContainer account={account} />}
-
-        <div className='account__header__image'>
-          <div className='account__header__info'>
-            {info}
-          </div>
-
-          {!(suspended || hidden) && <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' className='parallax' />}
-        </div>
-
-        <div className='account__header__bar'>
-          <div className='account__header__tabs'>
-            <a className='avatar' href={account.get('avatar')} rel='noopener' target='_blank' onClick={this.handleAvatarClick}>
-              <Avatar account={suspended || hidden ? undefined : account} size={90} />
-            </a>
-
-            <div className='account__header__tabs__buttons'>
-              {!hidden && bellBtn}
-              {!hidden && shareBtn}
-              <DropdownMenuContainer disabled={menu.length === 0} items={menu} icon='ellipsis-v' iconComponent={MoreHorizIcon} size={24} direction='right' />
-              {!hidden && actionBtn}
-            </div>
-          </div>
-
-          <div className='account__header__tabs__name'>
-            <h1>
-              <span dangerouslySetInnerHTML={displayNameHtml} />
-              <small>
-                <span>@{username}<span className='invisible'>@{domain}</span></span>
-                <DomainPill username={username} domain={domain} isSelf={me === account.get('id')} />
-                {lockedIcon}
-              </small>
-            </h1>
-          </div>
-
-          {badges.length > 0 && (
-            <div className='account__header__badges'>
-              {badges}
-            </div>
-          )}
-
-          {!(suspended || hidden) && (
-            <div className='account__header__extra'>
-              <div className='account__header__bio' ref={this.setRef}>
-                {(account.get('id') !== me && signedIn) && <AccountNoteContainer account={account} />}
-
-                {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content translate' dangerouslySetInnerHTML={content} />}
-
-                <div className='account__header__fields'>
-                  <dl>
-                    <dt><FormattedMessage id='account.joined_short' defaultMessage='Joined' /></dt>
-                    <dd>{intl.formatDate(account.get('created_at'), { year: 'numeric', month: 'short', day: '2-digit' })}</dd>
-                  </dl>
-
-                  {fields.map((pair, i) => (
-                    <dl key={i} className={classNames({ verified: pair.get('verified_at') })}>
-                      <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} className='translate' />
-
-                      <dd className='translate' title={pair.get('value_plain')}>
-                        {pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' icon={CheckIcon} className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
-                      </dd>
-                    </dl>
-                  ))}
-                </div>
-              </div>
-
-              <div className='account__header__extra__links'>
-                <NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/@${account.get('acct')}`} title={intl.formatNumber(account.get('statuses_count'))}>
-                  <ShortNumber
-                    value={account.get('statuses_count')}
-                    renderer={StatusesCounter}
-                  />
-                </NavLink>
-
-                <NavLink exact activeClassName='active' to={`/@${account.get('acct')}/following`} title={intl.formatNumber(account.get('following_count'))}>
-                  <ShortNumber
-                    value={account.get('following_count')}
-                    renderer={FollowingCounter}
-                  />
-                </NavLink>
-
-                <NavLink exact activeClassName='active' to={`/@${account.get('acct')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
-                  <ShortNumber
-                    value={account.get('followers_count')}
-                    renderer={FollowersCounter}
-                  />
-                </NavLink>
-              </div>
-            </div>
-          )}
-        </div>
-
-        <Helmet>
-          <title>{titleFromAccount(account)}</title>
-          <meta name='robots' content={(isLocal && isIndexable) ? 'all' : 'noindex'} />
-          <link rel='canonical' href={account.get('url')} />
-        </Helmet>
-      </div>
-    );
-  }
-
-}
-
-export default withRouter(withIdentity(injectIntl(Header)));
diff --git a/app/javascript/mastodon/features/account_gallery/index.jsx b/app/javascript/mastodon/features/account_gallery/index.jsx
index 35a0fbd2c6..695a1a2ad0 100644
--- a/app/javascript/mastodon/features/account_gallery/index.jsx
+++ b/app/javascript/mastodon/features/account_gallery/index.jsx
@@ -17,7 +17,7 @@ import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
 import { getAccountGallery } from 'mastodon/selectors';
 
 import { expandAccountMediaTimeline } from '../../actions/timelines';
-import HeaderContainer from '../account_timeline/containers/header_container';
+import { AccountHeader } from '../account_timeline/components/account_header';
 import Column from '../ui/components/column';
 
 import { MediaItem } from './components/media_item';
@@ -207,7 +207,7 @@ class AccountGallery extends ImmutablePureComponent {
 
         <ScrollContainer scrollKey='account_gallery'>
           <div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
-            <HeaderContainer accountId={this.props.accountId} />
+            <AccountHeader accountId={this.props.accountId} />
 
             {(suspended || blockedBy) ? (
               <div className='empty-column-indicator'>
diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx
new file mode 100644
index 0000000000..9970e27d06
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx
@@ -0,0 +1,1057 @@
+import { useCallback, useMemo } from 'react';
+
+import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
+
+import classNames from 'classnames';
+import { Helmet } from 'react-helmet';
+import { NavLink } from 'react-router-dom';
+
+import { useLinks } from '@/hooks/useLinks';
+import CheckIcon from '@/material-icons/400-24px/check.svg?react';
+import LockIcon from '@/material-icons/400-24px/lock.svg?react';
+import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
+import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
+import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react';
+import ShareIcon from '@/material-icons/400-24px/share.svg?react';
+import {
+  followAccount,
+  unblockAccount,
+  unmuteAccount,
+  pinAccount,
+  unpinAccount,
+} from 'mastodon/actions/accounts';
+import { initBlockModal } from 'mastodon/actions/blocks';
+import { mentionCompose, directCompose } from 'mastodon/actions/compose';
+import {
+  initDomainBlockModal,
+  unblockDomain,
+} from 'mastodon/actions/domain_blocks';
+import { openModal } from 'mastodon/actions/modal';
+import { initMuteModal } from 'mastodon/actions/mutes';
+import { initReport } from 'mastodon/actions/reports';
+import { Avatar } from 'mastodon/components/avatar';
+import { Badge, AutomatedBadge, GroupBadge } from 'mastodon/components/badge';
+import { Button } from 'mastodon/components/button';
+import { CopyIconButton } from 'mastodon/components/copy_icon_button';
+import {
+  FollowersCounter,
+  FollowingCounter,
+  StatusesCounter,
+} from 'mastodon/components/counters';
+import { Icon } from 'mastodon/components/icon';
+import { IconButton } from 'mastodon/components/icon_button';
+import { LoadingIndicator } from 'mastodon/components/loading_indicator';
+import { ShortNumber } from 'mastodon/components/short_number';
+import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
+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 { useIdentity } from 'mastodon/identity_context';
+import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state';
+import type { Account } from 'mastodon/models/account';
+import type { DropdownMenu } from 'mastodon/models/dropdown_menu';
+import type { Relationship } from 'mastodon/models/relationship';
+import {
+  PERMISSION_MANAGE_USERS,
+  PERMISSION_MANAGE_FEDERATION,
+} from 'mastodon/permissions';
+import { getAccountHidden } from 'mastodon/selectors/accounts';
+import { useAppSelector, useAppDispatch } from 'mastodon/store';
+
+import MemorialNote from './memorial_note';
+import MovedNote from './moved_note';
+
+const messages = defineMessages({
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
+  mutual: { id: 'account.mutual', defaultMessage: 'Mutual' },
+  cancel_follow_request: {
+    id: 'account.cancel_follow_request',
+    defaultMessage: 'Withdraw follow request',
+  },
+  requested: {
+    id: 'account.requested',
+    defaultMessage: 'Awaiting approval. Click to cancel follow request',
+  },
+  unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+  edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
+  linkVerifiedOn: {
+    id: 'account.link_verified_on',
+    defaultMessage: 'Ownership of this link was checked on {date}',
+  },
+  account_locked: {
+    id: 'account.locked_info',
+    defaultMessage:
+      'This account privacy status is set to locked. The owner manually reviews who can follow them.',
+  },
+  mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
+  direct: { id: 'account.direct', defaultMessage: 'Privately mention @{name}' },
+  unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+  block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+  mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
+  report: { id: 'account.report', defaultMessage: 'Report @{name}' },
+  share: { id: 'account.share', defaultMessage: "Share @{name}'s profile" },
+  copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' },
+  media: { id: 'account.media', defaultMessage: 'Media' },
+  blockDomain: {
+    id: 'account.block_domain',
+    defaultMessage: 'Block domain {domain}',
+  },
+  unblockDomain: {
+    id: 'account.unblock_domain',
+    defaultMessage: 'Unblock domain {domain}',
+  },
+  hideReblogs: {
+    id: 'account.hide_reblogs',
+    defaultMessage: 'Hide boosts from @{name}',
+  },
+  showReblogs: {
+    id: 'account.show_reblogs',
+    defaultMessage: 'Show boosts from @{name}',
+  },
+  enableNotifications: {
+    id: 'account.enable_notifications',
+    defaultMessage: 'Notify me when @{name} posts',
+  },
+  disableNotifications: {
+    id: 'account.disable_notifications',
+    defaultMessage: 'Stop notifying me when @{name} posts',
+  },
+  pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
+  preferences: {
+    id: 'navigation_bar.preferences',
+    defaultMessage: 'Preferences',
+  },
+  follow_requests: {
+    id: 'navigation_bar.follow_requests',
+    defaultMessage: 'Follow requests',
+  },
+  favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
+  lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+  followed_tags: {
+    id: 'navigation_bar.followed_tags',
+    defaultMessage: 'Followed hashtags',
+  },
+  blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
+  domain_blocks: {
+    id: 'navigation_bar.domain_blocks',
+    defaultMessage: 'Blocked domains',
+  },
+  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",
+  },
+  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}',
+  },
+  admin_domain: {
+    id: 'status.admin_domain',
+    defaultMessage: 'Open moderation interface for {domain}',
+  },
+  languages: {
+    id: 'account.languages',
+    defaultMessage: 'Change subscribed languages',
+  },
+  openOriginalPage: {
+    id: 'account.open_original_page',
+    defaultMessage: 'Open original page',
+  },
+});
+
+const titleFromAccount = (account: Account) => {
+  const displayName = account.display_name;
+  const acct =
+    account.acct === account.username
+      ? `${account.username}@${localDomain}`
+      : account.acct;
+  const prefix =
+    displayName.trim().length === 0 ? account.username : displayName;
+
+  return `${prefix} (@${acct})`;
+};
+
+const messageForFollowButton = (relationship?: Relationship) => {
+  if (!relationship) return messages.follow;
+
+  if (relationship.get('following') && relationship.get('followed_by')) {
+    return messages.mutual;
+  } else if (relationship.get('following') || relationship.get('requested')) {
+    return messages.unfollow;
+  } else if (relationship.get('followed_by')) {
+    return messages.followBack;
+  } else {
+    return messages.follow;
+  }
+};
+
+const dateFormatOptions: Intl.DateTimeFormatOptions = {
+  month: 'short',
+  day: 'numeric',
+  year: 'numeric',
+  hour: '2-digit',
+  minute: '2-digit',
+};
+
+export const AccountHeader: React.FC<{
+  accountId: string;
+  hideTabs?: boolean;
+}> = ({ accountId, hideTabs }) => {
+  const dispatch = useAppDispatch();
+  const intl = useIntl();
+  const { signedIn, permissions } = useIdentity();
+  const account = useAppSelector((state) => state.accounts.get(accountId));
+  const relationship = useAppSelector((state) =>
+    state.relationships.get(accountId),
+  );
+  const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
+  const handleLinkClick = useLinks();
+
+  const handleFollow = useCallback(() => {
+    if (!account) {
+      return;
+    }
+
+    if (relationship?.following || relationship?.requested) {
+      dispatch(
+        openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
+      );
+    } else {
+      dispatch(followAccount(account.id));
+    }
+  }, [dispatch, account, relationship]);
+
+  const handleBlock = useCallback(() => {
+    if (!account) {
+      return;
+    }
+
+    if (relationship?.blocking) {
+      dispatch(unblockAccount(account.id));
+    } else {
+      dispatch(initBlockModal(account));
+    }
+  }, [dispatch, account, relationship]);
+
+  const handleMention = useCallback(() => {
+    if (!account) {
+      return;
+    }
+
+    dispatch(mentionCompose(account));
+  }, [dispatch, account]);
+
+  const handleDirect = useCallback(() => {
+    if (!account) {
+      return;
+    }
+
+    dispatch(directCompose(account));
+  }, [dispatch, account]);
+
+  const handleReport = useCallback(() => {
+    if (!account) {
+      return;
+    }
+
+    dispatch(initReport(account));
+  }, [dispatch, account]);
+
+  const handleReblogToggle = useCallback(() => {
+    if (!account) {
+      return;
+    }
+
+    if (relationship?.showing_reblogs) {
+      dispatch(followAccount(account.id, { reblogs: false }));
+    } else {
+      dispatch(followAccount(account.id, { reblogs: true }));
+    }
+  }, [dispatch, account, relationship]);
+
+  const handleNotifyToggle = useCallback(() => {
+    if (!account) {
+      return;
+    }
+
+    if (relationship?.notifying) {
+      dispatch(followAccount(account.id, { notify: false }));
+    } else {
+      dispatch(followAccount(account.id, { notify: true }));
+    }
+  }, [dispatch, account, relationship]);
+
+  const handleMute = useCallback(() => {
+    if (!account) {
+      return;
+    }
+
+    if (relationship?.muting) {
+      dispatch(unmuteAccount(account.id));
+    } else {
+      dispatch(initMuteModal(account));
+    }
+  }, [dispatch, account, relationship]);
+
+  const handleBlockDomain = useCallback(() => {
+    if (!account) {
+      return;
+    }
+
+    dispatch(initDomainBlockModal(account));
+  }, [dispatch, account]);
+
+  const handleUnblockDomain = useCallback(() => {
+    if (!account) {
+      return;
+    }
+
+    const domain = account.acct.split('@')[1];
+
+    if (!domain) {
+      return;
+    }
+
+    dispatch(unblockDomain(domain));
+  }, [dispatch, account]);
+
+  const handleEndorseToggle = useCallback(() => {
+    if (!account) {
+      return;
+    }
+
+    if (relationship?.endorsed) {
+      dispatch(unpinAccount(account.id));
+    } else {
+      dispatch(pinAccount(account.id));
+    }
+  }, [dispatch, account, relationship]);
+
+  const handleAddToList = useCallback(() => {
+    if (!account) {
+      return;
+    }
+
+    dispatch(
+      openModal({
+        modalType: 'LIST_ADDER',
+        modalProps: {
+          accountId: account.id,
+        },
+      }),
+    );
+  }, [dispatch, account]);
+
+  const handleChangeLanguages = useCallback(() => {
+    if (!account) {
+      return;
+    }
+
+    dispatch(
+      openModal({
+        modalType: 'SUBSCRIBED_LANGUAGES',
+        modalProps: {
+          accountId: account.id,
+        },
+      }),
+    );
+  }, [dispatch, account]);
+
+  const handleInteractionModal = useCallback(() => {
+    if (!account) {
+      return;
+    }
+
+    dispatch(
+      openModal({
+        modalType: 'INTERACTION',
+        modalProps: {
+          type: 'follow',
+          accountId: account.id,
+          url: account.uri,
+        },
+      }),
+    );
+  }, [dispatch, account]);
+
+  const handleOpenAvatar = useCallback(
+    (e: React.MouseEvent) => {
+      if (e.button !== 0 || e.ctrlKey || e.metaKey) {
+        return;
+      }
+
+      e.preventDefault();
+
+      if (!account) {
+        return;
+      }
+
+      dispatch(
+        openModal({
+          modalType: 'IMAGE',
+          modalProps: {
+            src: account.avatar,
+            alt: '',
+          },
+        }),
+      );
+    },
+    [dispatch, account],
+  );
+
+  const handleShare = useCallback(() => {
+    if (!account) {
+      return;
+    }
+
+    void navigator.share({
+      url: account.url,
+    });
+  }, [account]);
+
+  const handleEditProfile = useCallback(() => {
+    window.open('/settings/profile', '_blank');
+  }, []);
+
+  const handleMouseEnter = useCallback(
+    ({ currentTarget }: React.MouseEvent) => {
+      if (autoPlayGif) {
+        return;
+      }
+
+      currentTarget
+        .querySelectorAll<HTMLImageElement>('.custom-emoji')
+        .forEach((emoji) => {
+          emoji.src = emoji.getAttribute('data-original') ?? '';
+        });
+    },
+    [],
+  );
+
+  const handleMouseLeave = useCallback(
+    ({ currentTarget }: React.MouseEvent) => {
+      if (autoPlayGif) {
+        return;
+      }
+
+      currentTarget
+        .querySelectorAll<HTMLImageElement>('.custom-emoji')
+        .forEach((emoji) => {
+          emoji.src = emoji.getAttribute('data-static') ?? '';
+        });
+    },
+    [],
+  );
+
+  const suspended = account?.suspended;
+  const isRemote = account?.acct !== account?.username;
+  const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
+
+  const menu = useMemo(() => {
+    const arr: DropdownMenu = [];
+
+    if (!account) {
+      return arr;
+    }
+
+    if (signedIn && account.id !== me && !account.suspended) {
+      arr.push({
+        text: intl.formatMessage(messages.mention, {
+          name: account.username,
+        }),
+        action: handleMention,
+      });
+      arr.push({
+        text: intl.formatMessage(messages.direct, {
+          name: account.username,
+        }),
+        action: handleDirect,
+      });
+      arr.push(null);
+    }
+
+    if (isRemote) {
+      arr.push({
+        text: intl.formatMessage(messages.openOriginalPage),
+        href: account.url,
+      });
+      arr.push(null);
+    }
+
+    if (account.id === me) {
+      arr.push({
+        text: intl.formatMessage(messages.edit_profile),
+        href: '/settings/profile',
+      });
+      arr.push({
+        text: intl.formatMessage(messages.preferences),
+        href: '/settings/preferences',
+      });
+      arr.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
+      arr.push(null);
+      arr.push({
+        text: intl.formatMessage(messages.follow_requests),
+        to: '/follow_requests',
+      });
+      arr.push({
+        text: intl.formatMessage(messages.favourites),
+        to: '/favourites',
+      });
+      arr.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
+      arr.push({
+        text: intl.formatMessage(messages.followed_tags),
+        to: '/followed_tags',
+      });
+      arr.push(null);
+      arr.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
+      arr.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
+      arr.push({
+        text: intl.formatMessage(messages.domain_blocks),
+        to: '/domain_blocks',
+      });
+    } else if (signedIn) {
+      if (relationship?.following) {
+        if (!relationship.muting) {
+          if (relationship.showing_reblogs) {
+            arr.push({
+              text: intl.formatMessage(messages.hideReblogs, {
+                name: account.username,
+              }),
+              action: handleReblogToggle,
+            });
+          } else {
+            arr.push({
+              text: intl.formatMessage(messages.showReblogs, {
+                name: account.username,
+              }),
+              action: handleReblogToggle,
+            });
+          }
+
+          arr.push({
+            text: intl.formatMessage(messages.languages),
+            action: handleChangeLanguages,
+          });
+          arr.push(null);
+        }
+
+        arr.push({
+          text: intl.formatMessage(
+            account.getIn(['relationship', 'endorsed'])
+              ? messages.unendorse
+              : messages.endorse,
+          ),
+          action: handleEndorseToggle,
+        });
+        arr.push({
+          text: intl.formatMessage(messages.add_or_remove_from_list),
+          action: handleAddToList,
+        });
+        arr.push(null);
+      }
+
+      if (relationship?.muting) {
+        arr.push({
+          text: intl.formatMessage(messages.unmute, {
+            name: account.username,
+          }),
+          action: handleMute,
+        });
+      } else {
+        arr.push({
+          text: intl.formatMessage(messages.mute, {
+            name: account.username,
+          }),
+          action: handleMute,
+          dangerous: true,
+        });
+      }
+
+      if (relationship?.blocking) {
+        arr.push({
+          text: intl.formatMessage(messages.unblock, {
+            name: account.username,
+          }),
+          action: handleBlock,
+        });
+      } else {
+        arr.push({
+          text: intl.formatMessage(messages.block, {
+            name: account.username,
+          }),
+          action: handleBlock,
+          dangerous: true,
+        });
+      }
+
+      if (!account.suspended) {
+        arr.push({
+          text: intl.formatMessage(messages.report, {
+            name: account.username,
+          }),
+          action: handleReport,
+          dangerous: true,
+        });
+      }
+    }
+
+    if (signedIn && isRemote) {
+      arr.push(null);
+
+      if (relationship?.domain_blocking) {
+        arr.push({
+          text: intl.formatMessage(messages.unblockDomain, {
+            domain: remoteDomain,
+          }),
+          action: handleUnblockDomain,
+        });
+      } else {
+        arr.push({
+          text: intl.formatMessage(messages.blockDomain, {
+            domain: remoteDomain,
+          }),
+          action: handleBlockDomain,
+          dangerous: true,
+        });
+      }
+    }
+
+    if (
+      (account.id !== me &&
+        (permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) ||
+      (isRemote &&
+        (permissions & PERMISSION_MANAGE_FEDERATION) ===
+          PERMISSION_MANAGE_FEDERATION)
+    ) {
+      arr.push(null);
+      if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
+        arr.push({
+          text: intl.formatMessage(messages.admin_account, {
+            name: account.username,
+          }),
+          href: `/admin/accounts/${account.id}`,
+        });
+      }
+      if (
+        isRemote &&
+        (permissions & PERMISSION_MANAGE_FEDERATION) ===
+          PERMISSION_MANAGE_FEDERATION
+      ) {
+        arr.push({
+          text: intl.formatMessage(messages.admin_domain, {
+            domain: remoteDomain,
+          }),
+          href: `/admin/instances/${remoteDomain}`,
+        });
+      }
+    }
+
+    return arr;
+  }, [
+    account,
+    relationship,
+    permissions,
+    isRemote,
+    remoteDomain,
+    intl,
+    signedIn,
+    handleAddToList,
+    handleBlock,
+    handleBlockDomain,
+    handleChangeLanguages,
+    handleDirect,
+    handleEndorseToggle,
+    handleMention,
+    handleMute,
+    handleReblogToggle,
+    handleReport,
+    handleUnblockDomain,
+  ]);
+
+  if (!account) {
+    return null;
+  }
+
+  let actionBtn, bellBtn, lockedIcon, shareBtn;
+
+  const info = [];
+
+  if (me !== account.id && relationship?.blocking) {
+    info.push(
+      <span key='blocked' className='relationship-tag'>
+        <FormattedMessage id='account.blocked' defaultMessage='Blocked' />
+      </span>,
+    );
+  }
+
+  if (me !== account.id && relationship?.muting) {
+    info.push(
+      <span key='muted' className='relationship-tag'>
+        <FormattedMessage id='account.muted' defaultMessage='Muted' />
+      </span>,
+    );
+  } else if (me !== account.id && relationship?.domain_blocking) {
+    info.push(
+      <span key='domain_blocked' className='relationship-tag'>
+        <FormattedMessage
+          id='account.domain_blocked'
+          defaultMessage='Domain blocked'
+        />
+      </span>,
+    );
+  }
+
+  if (relationship?.requested || relationship?.following) {
+    bellBtn = (
+      <IconButton
+        icon={relationship.notifying ? 'bell' : 'bell-o'}
+        iconComponent={
+          relationship.notifying ? NotificationsActiveIcon : NotificationsIcon
+        }
+        active={relationship.notifying}
+        title={intl.formatMessage(
+          relationship.notifying
+            ? messages.disableNotifications
+            : messages.enableNotifications,
+          { name: account.username },
+        )}
+        onClick={handleNotifyToggle}
+      />
+    );
+  }
+
+  if ('share' in navigator) {
+    shareBtn = (
+      <IconButton
+        className='optional'
+        icon=''
+        iconComponent={ShareIcon}
+        title={intl.formatMessage(messages.share, {
+          name: account.username,
+        })}
+        onClick={handleShare}
+      />
+    );
+  } else {
+    shareBtn = (
+      <CopyIconButton
+        className='optional'
+        title={intl.formatMessage(messages.copy)}
+        value={account.url}
+      />
+    );
+  }
+
+  if (me !== account.id) {
+    if (signedIn && !relationship) {
+      // Wait until the relationship is loaded
+      actionBtn = (
+        <Button disabled>
+          <LoadingIndicator />
+        </Button>
+      );
+    } else if (!relationship?.blocking) {
+      actionBtn = (
+        <Button
+          disabled={relationship?.blocked_by}
+          className={classNames({
+            'button--destructive':
+              relationship?.following || relationship?.requested,
+          })}
+          text={intl.formatMessage(messageForFollowButton(relationship))}
+          onClick={signedIn ? handleFollow : handleInteractionModal}
+        />
+      );
+    } else {
+      actionBtn = (
+        <Button
+          text={intl.formatMessage(messages.unblock, {
+            name: account.username,
+          })}
+          onClick={handleBlock}
+        />
+      );
+    }
+  } else {
+    actionBtn = (
+      <Button
+        text={intl.formatMessage(messages.edit_profile)}
+        onClick={handleEditProfile}
+      />
+    );
+  }
+
+  if (account.moved && !relationship?.following) {
+    actionBtn = '';
+  }
+
+  if (account.locked) {
+    lockedIcon = (
+      <Icon
+        id='lock'
+        icon={LockIcon}
+        title={intl.formatMessage(messages.account_locked)}
+      />
+    );
+  }
+
+  const content = { __html: account.note_emojified };
+  const displayNameHtml = { __html: account.display_name_html };
+  const fields = account.fields;
+  const isLocal = !account.acct.includes('@');
+  const username = account.acct.split('@')[0];
+  const domain = isLocal ? localDomain : account.acct.split('@')[1];
+  const isIndexable = !account.noindex;
+
+  const badges = [];
+
+  if (account.bot) {
+    badges.push(<AutomatedBadge key='bot-badge' />);
+  } else if (account.group) {
+    badges.push(<GroupBadge key='group-badge' />);
+  }
+
+  account.get('roles', []).forEach((role) => {
+    badges.push(
+      <Badge
+        key={`role-badge-${role.get('id')}`}
+        label={<span>{role.get('name')}</span>}
+        domain={domain}
+        roleId={role.get('id')}
+      />,
+    );
+  });
+
+  return (
+    <div className='account-timeline__header'>
+      {!hidden && account.memorial && <MemorialNote />}
+      {!hidden && account.moved && (
+        <MovedNote from={account} to={account.moved} />
+      )}
+
+      <div
+        className={classNames('account__header', {
+          inactive: !!account.moved,
+        })}
+        onMouseEnter={handleMouseEnter}
+        onMouseLeave={handleMouseLeave}
+      >
+        {!(suspended || hidden || account.moved) &&
+          relationship?.requested_by && (
+            <FollowRequestNoteContainer account={account} />
+          )}
+
+        <div className='account__header__image'>
+          <div className='account__header__info'>{info}</div>
+
+          {!(suspended || hidden) && (
+            <img
+              src={autoPlayGif ? account.header : account.header_static}
+              alt=''
+              className='parallax'
+            />
+          )}
+        </div>
+
+        <div className='account__header__bar'>
+          <div className='account__header__tabs'>
+            <a
+              className='avatar'
+              href={account.avatar}
+              rel='noopener'
+              target='_blank'
+              onClick={handleOpenAvatar}
+            >
+              <Avatar
+                account={suspended || hidden ? undefined : account}
+                size={90}
+              />
+            </a>
+
+            <div className='account__header__tabs__buttons'>
+              {!hidden && bellBtn}
+              {!hidden && shareBtn}
+              <DropdownMenuContainer
+                disabled={menu.length === 0}
+                items={menu}
+                icon='ellipsis-v'
+                iconComponent={MoreHorizIcon}
+                size={24}
+                direction='right'
+              />
+              {!hidden && actionBtn}
+            </div>
+          </div>
+
+          <div className='account__header__tabs__name'>
+            <h1>
+              <span dangerouslySetInnerHTML={displayNameHtml} />
+              <small>
+                <span>
+                  @{username}
+                  <span className='invisible'>@{domain}</span>
+                </span>
+                <DomainPill
+                  username={username ?? ''}
+                  domain={domain ?? ''}
+                  isSelf={me === account.id}
+                />
+                {lockedIcon}
+              </small>
+            </h1>
+          </div>
+
+          {badges.length > 0 && (
+            <div className='account__header__badges'>{badges}</div>
+          )}
+
+          {!(suspended || hidden) && (
+            <div className='account__header__extra'>
+              <div
+                className='account__header__bio'
+                onClickCapture={handleLinkClick}
+              >
+                {account.id !== me && signedIn && (
+                  <AccountNoteContainer account={account} />
+                )}
+
+                {account.note.length > 0 && account.note !== '<p></p>' && (
+                  <div
+                    className='account__header__content translate'
+                    dangerouslySetInnerHTML={content}
+                  />
+                )}
+
+                <div className='account__header__fields'>
+                  <dl>
+                    <dt>
+                      <FormattedMessage
+                        id='account.joined_short'
+                        defaultMessage='Joined'
+                      />
+                    </dt>
+                    <dd>
+                      {intl.formatDate(account.created_at, {
+                        year: 'numeric',
+                        month: 'short',
+                        day: '2-digit',
+                      })}
+                    </dd>
+                  </dl>
+
+                  {fields.map((pair, i) => (
+                    <dl
+                      key={i}
+                      className={classNames({
+                        verified: pair.verified_at,
+                      })}
+                    >
+                      <dt
+                        dangerouslySetInnerHTML={{
+                          __html: pair.name_emojified,
+                        }}
+                        title={pair.name}
+                        className='translate'
+                      />
+
+                      <dd className='translate' title={pair.value_plain ?? ''}>
+                        {pair.verified_at && (
+                          <span
+                            title={intl.formatMessage(messages.linkVerifiedOn, {
+                              date: intl.formatDate(
+                                pair.verified_at,
+                                dateFormatOptions,
+                              ),
+                            })}
+                          >
+                            <Icon
+                              id='check'
+                              icon={CheckIcon}
+                              className='verified__mark'
+                            />
+                          </span>
+                        )}{' '}
+                        <span
+                          dangerouslySetInnerHTML={{
+                            __html: pair.value_emojified,
+                          }}
+                        />
+                      </dd>
+                    </dl>
+                  ))}
+                </div>
+              </div>
+
+              <div className='account__header__extra__links'>
+                <NavLink
+                  to={`/@${account.acct}`}
+                  title={intl.formatNumber(account.statuses_count)}
+                >
+                  <ShortNumber
+                    value={account.statuses_count}
+                    renderer={StatusesCounter}
+                  />
+                </NavLink>
+
+                <NavLink
+                  exact
+                  to={`/@${account.acct}/following`}
+                  title={intl.formatNumber(account.following_count)}
+                >
+                  <ShortNumber
+                    value={account.following_count}
+                    renderer={FollowingCounter}
+                  />
+                </NavLink>
+
+                <NavLink
+                  exact
+                  to={`/@${account.acct}/followers`}
+                  title={intl.formatNumber(account.followers_count)}
+                >
+                  <ShortNumber
+                    value={account.followers_count}
+                    renderer={FollowersCounter}
+                  />
+                </NavLink>
+              </div>
+            </div>
+          )}
+        </div>
+      </div>
+
+      {!(hideTabs || hidden) && (
+        <div className='account__section-headline'>
+          <NavLink exact to={`/@${account.acct}`}>
+            <FormattedMessage id='account.posts' defaultMessage='Posts' />
+          </NavLink>
+          <NavLink exact to={`/@${account.acct}/with_replies`}>
+            <FormattedMessage
+              id='account.posts_with_replies'
+              defaultMessage='Posts and replies'
+            />
+          </NavLink>
+          <NavLink exact to={`/@${account.acct}/media`}>
+            <FormattedMessage id='account.media' defaultMessage='Media' />
+          </NavLink>
+        </div>
+      )}
+
+      <Helmet>
+        <title>{titleFromAccount(account)}</title>
+        <meta
+          name='robots'
+          content={isLocal && isIndexable ? 'all' : 'noindex'}
+        />
+        <link rel='canonical' href={account.url} />
+      </Helmet>
+    </div>
+  );
+};
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.jsx b/app/javascript/mastodon/features/account_timeline/components/header.jsx
deleted file mode 100644
index 403c423025..0000000000
--- a/app/javascript/mastodon/features/account_timeline/components/header.jsx
+++ /dev/null
@@ -1,155 +0,0 @@
-import PropTypes from 'prop-types';
-
-import { FormattedMessage } from 'react-intl';
-
-import { NavLink } from 'react-router-dom';
-
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-import InnerHeader from '../../account/components/header';
-
-import MemorialNote from './memorial_note';
-import MovedNote from './moved_note';
-
-class Header extends ImmutablePureComponent {
-
-  static propTypes = {
-    account: ImmutablePropTypes.record,
-    onFollow: PropTypes.func.isRequired,
-    onBlock: PropTypes.func.isRequired,
-    onMention: PropTypes.func.isRequired,
-    onDirect: PropTypes.func.isRequired,
-    onReblogToggle: PropTypes.func.isRequired,
-    onReport: PropTypes.func.isRequired,
-    onMute: PropTypes.func.isRequired,
-    onBlockDomain: PropTypes.func.isRequired,
-    onUnblockDomain: PropTypes.func.isRequired,
-    onEndorseToggle: PropTypes.func.isRequired,
-    onAddToList: PropTypes.func.isRequired,
-    onChangeLanguages: PropTypes.func.isRequired,
-    onInteractionModal: PropTypes.func.isRequired,
-    onOpenAvatar: PropTypes.func.isRequired,
-    onOpenURL: PropTypes.func.isRequired,
-    hideTabs: PropTypes.bool,
-    domain: PropTypes.string.isRequired,
-    hidden: PropTypes.bool,
-  };
-
-  handleFollow = () => {
-    this.props.onFollow(this.props.account);
-  };
-
-  handleBlock = () => {
-    this.props.onBlock(this.props.account);
-  };
-
-  handleMention = () => {
-    this.props.onMention(this.props.account);
-  };
-
-  handleDirect = () => {
-    this.props.onDirect(this.props.account);
-  };
-
-  handleReport = () => {
-    this.props.onReport(this.props.account);
-  };
-
-  handleReblogToggle = () => {
-    this.props.onReblogToggle(this.props.account);
-  };
-
-  handleNotifyToggle = () => {
-    this.props.onNotifyToggle(this.props.account);
-  };
-
-  handleMute = () => {
-    this.props.onMute(this.props.account);
-  };
-
-  handleBlockDomain = () => {
-    this.props.onBlockDomain(this.props.account);
-  };
-
-  handleUnblockDomain = () => {
-    const domain = this.props.account.get('acct').split('@')[1];
-
-    if (!domain) return;
-
-    this.props.onUnblockDomain(domain);
-  };
-
-  handleEndorseToggle = () => {
-    this.props.onEndorseToggle(this.props.account);
-  };
-
-  handleAddToList = () => {
-    this.props.onAddToList(this.props.account);
-  };
-
-  handleEditAccountNote = () => {
-    this.props.onEditAccountNote(this.props.account);
-  };
-
-  handleChangeLanguages = () => {
-    this.props.onChangeLanguages(this.props.account);
-  };
-
-  handleInteractionModal = () => {
-    this.props.onInteractionModal(this.props.account);
-  };
-
-  handleOpenAvatar = () => {
-    this.props.onOpenAvatar(this.props.account);
-  };
-
-  render () {
-    const { account, hidden, hideTabs } = this.props;
-
-    if (account === null) {
-      return null;
-    }
-
-    return (
-      <div className='account-timeline__header'>
-        {(!hidden && account.get('memorial')) && <MemorialNote />}
-        {(!hidden && account.get('moved')) && <MovedNote from={account} to={account.get('moved')} />}
-
-        <InnerHeader
-          account={account}
-          onFollow={this.handleFollow}
-          onBlock={this.handleBlock}
-          onMention={this.handleMention}
-          onDirect={this.handleDirect}
-          onReblogToggle={this.handleReblogToggle}
-          onNotifyToggle={this.handleNotifyToggle}
-          onReport={this.handleReport}
-          onMute={this.handleMute}
-          onBlockDomain={this.handleBlockDomain}
-          onUnblockDomain={this.handleUnblockDomain}
-          onEndorseToggle={this.handleEndorseToggle}
-          onAddToList={this.handleAddToList}
-          onEditAccountNote={this.handleEditAccountNote}
-          onChangeLanguages={this.handleChangeLanguages}
-          onInteractionModal={this.handleInteractionModal}
-          onOpenAvatar={this.handleOpenAvatar}
-          onOpenURL={this.props.onOpenURL}
-          domain={this.props.domain}
-          hidden={hidden}
-        />
-
-        {!(hideTabs || hidden) && (
-          <div className='account__section-headline'>
-            <NavLink exact to={`/@${account.get('acct')}`}><FormattedMessage id='account.posts' defaultMessage='Posts' /></NavLink>
-            <NavLink exact to={`/@${account.get('acct')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Posts and replies' /></NavLink>
-            <NavLink exact to={`/@${account.get('acct')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink>
-          </div>
-        )}
-      </div>
-    );
-  }
-
-}
-
-export default Header;
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx b/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx
deleted file mode 100644
index 14050c25d1..0000000000
--- a/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx
+++ /dev/null
@@ -1,153 +0,0 @@
-import { injectIntl } from 'react-intl';
-
-import { connect } from 'react-redux';
-
-import { openURL } from 'mastodon/actions/search';
-
-import {
-  followAccount,
-  unblockAccount,
-  unmuteAccount,
-  pinAccount,
-  unpinAccount,
-} from '../../../actions/accounts';
-import { initBlockModal } from '../../../actions/blocks';
-import {
-  mentionCompose,
-  directCompose,
-} from '../../../actions/compose';
-import { initDomainBlockModal, unblockDomain } from '../../../actions/domain_blocks';
-import { openModal } from '../../../actions/modal';
-import { initMuteModal } from '../../../actions/mutes';
-import { initReport } from '../../../actions/reports';
-import { makeGetAccount, getAccountHidden } from '../../../selectors';
-import Header from '../components/header';
-
-const makeMapStateToProps = () => {
-  const getAccount = makeGetAccount();
-
-  const mapStateToProps = (state, { accountId }) => ({
-    account: getAccount(state, accountId),
-    domain: state.getIn(['meta', 'domain']),
-    hidden: getAccountHidden(state, accountId),
-  });
-
-  return mapStateToProps;
-};
-
-const mapDispatchToProps = (dispatch) => ({
-
-  onFollow (account) {
-    if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
-      dispatch(openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }));
-    } else {
-      dispatch(followAccount(account.get('id')));
-    }
-  },
-
-  onInteractionModal (account) {
-    dispatch(openModal({
-      modalType: 'INTERACTION',
-      modalProps: {
-        type: 'follow',
-        accountId: account.get('id'),
-        url: account.get('uri'),
-      },
-    }));
-  },
-
-  onBlock (account) {
-    if (account.getIn(['relationship', 'blocking'])) {
-      dispatch(unblockAccount(account.get('id')));
-    } else {
-      dispatch(initBlockModal(account));
-    }
-  },
-
-  onMention (account) {
-    dispatch(mentionCompose(account));
-  },
-
-  onDirect (account) {
-    dispatch(directCompose(account));
-  },
-
-  onReblogToggle (account) {
-    if (account.getIn(['relationship', 'showing_reblogs'])) {
-      dispatch(followAccount(account.get('id'), { reblogs: false }));
-    } else {
-      dispatch(followAccount(account.get('id'), { reblogs: true }));
-    }
-  },
-
-  onEndorseToggle (account) {
-    if (account.getIn(['relationship', 'endorsed'])) {
-      dispatch(unpinAccount(account.get('id')));
-    } else {
-      dispatch(pinAccount(account.get('id')));
-    }
-  },
-
-  onNotifyToggle (account) {
-    if (account.getIn(['relationship', 'notifying'])) {
-      dispatch(followAccount(account.get('id'), { notify: false }));
-    } else {
-      dispatch(followAccount(account.get('id'), { notify: true }));
-    }
-  },
-
-  onReport (account) {
-    dispatch(initReport(account));
-  },
-
-  onMute (account) {
-    if (account.getIn(['relationship', 'muting'])) {
-      dispatch(unmuteAccount(account.get('id')));
-    } else {
-      dispatch(initMuteModal(account));
-    }
-  },
-
-  onBlockDomain (account) {
-    dispatch(initDomainBlockModal(account));
-  },
-
-  onUnblockDomain (domain) {
-    dispatch(unblockDomain(domain));
-  },
-
-  onAddToList (account) {
-    dispatch(openModal({
-      modalType: 'LIST_ADDER',
-      modalProps: {
-        accountId: account.get('id'),
-      },
-    }));
-  },
-
-  onChangeLanguages (account) {
-    dispatch(openModal({
-      modalType: 'SUBSCRIBED_LANGUAGES',
-      modalProps: {
-        accountId: account.get('id'),
-      },
-    }));
-  },
-
-  onOpenAvatar (account) {
-    dispatch(openModal({
-      modalType: 'IMAGE',
-      modalProps: {
-        src: account.get('avatar'),
-        alt: '',
-      },
-    }));
-  },
-
-  onOpenURL (url) {
-    return dispatch(openURL({ url }));
-  },
-
-});
-
-export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));
diff --git a/app/javascript/mastodon/features/account_timeline/index.jsx b/app/javascript/mastodon/features/account_timeline/index.jsx
index 105c2e4e50..886191e668 100644
--- a/app/javascript/mastodon/features/account_timeline/index.jsx
+++ b/app/javascript/mastodon/features/account_timeline/index.jsx
@@ -11,7 +11,7 @@ import { TimelineHint } from 'mastodon/components/timeline_hint';
 import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
 import { me } from 'mastodon/initial_state';
 import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
-import { getAccountHidden } from 'mastodon/selectors';
+import { getAccountHidden } from 'mastodon/selectors/accounts';
 import { useAppSelector } from 'mastodon/store';
 
 import { lookupAccount, fetchAccount } from '../../actions/accounts';
@@ -22,8 +22,8 @@ import { LoadingIndicator } from '../../components/loading_indicator';
 import StatusList from '../../components/status_list';
 import Column from '../ui/components/column';
 
+import { AccountHeader } from './components/account_header';
 import { LimitedAccountHint } from './components/limited_account_hint';
-import HeaderContainer from './containers/header_container';
 
 const emptyList = ImmutableList();
 
@@ -198,7 +198,7 @@ class AccountTimeline extends ImmutablePureComponent {
         <ColumnBackButton />
 
         <StatusList
-          prepend={<HeaderContainer accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />}
+          prepend={<AccountHeader accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />}
           alwaysPrepend
           append={remoteMessage}
           scrollKey='account_timeline'
diff --git a/app/javascript/mastodon/features/followers/index.jsx b/app/javascript/mastodon/features/followers/index.jsx
index c13033b289..eaafb3d193 100644
--- a/app/javascript/mastodon/features/followers/index.jsx
+++ b/app/javascript/mastodon/features/followers/index.jsx
@@ -10,9 +10,10 @@ import { debounce } from 'lodash';
 
 import { Account } from 'mastodon/components/account';
 import { TimelineHint } from 'mastodon/components/timeline_hint';
+import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
 import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
 import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
-import { getAccountHidden } from 'mastodon/selectors';
+import { getAccountHidden } from 'mastodon/selectors/accounts';
 import { useAppSelector } from 'mastodon/store';
 
 import {
@@ -25,7 +26,6 @@ import { ColumnBackButton } from '../../components/column_back_button';
 import { LoadingIndicator } from '../../components/loading_indicator';
 import ScrollableList from '../../components/scrollable_list';
 import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint';
-import HeaderContainer from '../account_timeline/containers/header_container';
 import Column from '../ui/components/column';
 
 const mapStateToProps = (state, { params: { acct, id } }) => {
@@ -168,7 +168,7 @@ class Followers extends ImmutablePureComponent {
           hasMore={!forceEmptyState && hasMore}
           isLoading={isLoading}
           onLoadMore={this.handleLoadMore}
-          prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />}
+          prepend={<AccountHeader accountId={this.props.accountId} hideTabs />}
           alwaysPrepend
           append={remoteMessage}
           emptyMessage={emptyMessage}
diff --git a/app/javascript/mastodon/features/following/index.jsx b/app/javascript/mastodon/features/following/index.jsx
index d37c0c30ef..3200f1543b 100644
--- a/app/javascript/mastodon/features/following/index.jsx
+++ b/app/javascript/mastodon/features/following/index.jsx
@@ -10,9 +10,10 @@ import { debounce } from 'lodash';
 
 import { Account } from 'mastodon/components/account';
 import { TimelineHint } from 'mastodon/components/timeline_hint';
+import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
 import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
 import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
-import { getAccountHidden } from 'mastodon/selectors';
+import { getAccountHidden } from 'mastodon/selectors/accounts';
 import { useAppSelector } from 'mastodon/store';
 
 import {
@@ -25,7 +26,6 @@ import { ColumnBackButton } from '../../components/column_back_button';
 import { LoadingIndicator } from '../../components/loading_indicator';
 import ScrollableList from '../../components/scrollable_list';
 import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint';
-import HeaderContainer from '../account_timeline/containers/header_container';
 import Column from '../ui/components/column';
 
 const mapStateToProps = (state, { params: { acct, id } }) => {
@@ -168,7 +168,7 @@ class Following extends ImmutablePureComponent {
           hasMore={!forceEmptyState && hasMore}
           isLoading={isLoading}
           onLoadMore={this.handleLoadMore}
-          prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />}
+          prepend={<AccountHeader accountId={this.props.accountId} hideTabs />}
           alwaysPrepend
           append={remoteMessage}
           emptyMessage={emptyMessage}
diff --git a/app/javascript/mastodon/models/dropdown_menu.ts b/app/javascript/mastodon/models/dropdown_menu.ts
new file mode 100644
index 0000000000..35a29ab62a
--- /dev/null
+++ b/app/javascript/mastodon/models/dropdown_menu.ts
@@ -0,0 +1,24 @@
+interface BaseMenuItem {
+  text: string;
+  dangerous?: boolean;
+}
+
+interface ActionMenuItem extends BaseMenuItem {
+  action: () => void;
+}
+
+interface LinkMenuItem extends BaseMenuItem {
+  to: string;
+}
+
+interface ExternalLinkMenuItem extends BaseMenuItem {
+  href: string;
+}
+
+export type MenuItem =
+  | ActionMenuItem
+  | LinkMenuItem
+  | ExternalLinkMenuItem
+  | null;
+
+export type DropdownMenu = MenuItem[];
diff --git a/app/javascript/mastodon/selectors/accounts.ts b/app/javascript/mastodon/selectors/accounts.ts
index cee3a87bca..a33daee867 100644
--- a/app/javascript/mastodon/selectors/accounts.ts
+++ b/app/javascript/mastodon/selectors/accounts.ts
@@ -1,6 +1,7 @@
 import { createSelector } from '@reduxjs/toolkit';
 import { Record as ImmutableRecord } from 'immutable';
 
+import { me } from 'mastodon/initial_state';
 import { accountDefaultValues } from 'mastodon/models/account';
 import type { Account, AccountShape } from 'mastodon/models/account';
 import type { Relationship } from 'mastodon/models/relationship';
@@ -45,3 +46,16 @@ export function makeGetAccount() {
     },
   );
 }
+
+export const getAccountHidden = createSelector(
+  [
+    (state: RootState, id: string) => state.accounts.get(id)?.hidden,
+    (state: RootState, id: string) =>
+      state.relationships.get(id)?.following ||
+      state.relationships.get(id)?.requested,
+    (state: RootState, id: string) => id === me,
+  ],
+  (hidden, followingOrRequested, isSelf) => {
+    return hidden && !(isSelf || followingOrRequested);
+  },
+);
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index 345ceac49a..6d787272ea 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -93,27 +93,23 @@ export const makeGetReport = () => createSelector([
 
 export const getAccountGallery = createSelector([
   (state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()),
-  state       => state.get('statuses'),
+  state  => state.get('statuses'),
   (state, id) => state.getIn(['accounts', id]),
 ], (statusIds, statuses, account) => {
   let medias = ImmutableList();
 
   statusIds.forEach(statusId => {
-    const status = statuses.get(statusId).set('account', account);
-    medias = medias.concat(status.get('media_attachments').map(media => media.set('status', status)));
+    let status = statuses.get(statusId);
+
+    if (status) {
+      status = status.set('account', account);
+      medias = medias.concat(status.get('media_attachments').map(media => media.set('status', status)));
+    }
   });
 
   return medias;
 });
 
-export const getAccountHidden = createSelector([
-  (state, id) => state.getIn(['accounts', id, 'hidden']),
-  (state, id) => state.getIn(['relationships', id, 'following']) || state.getIn(['relationships', id, 'requested']),
-  (state, id) => id === me,
-], (hidden, followingOrRequested, isSelf) => {
-  return hidden && !(isSelf || followingOrRequested);
-});
-
 export const getStatusList = createSelector([
   (state, type) => state.getIn(['status_lists', type, 'items']),
 ], (items) => items.toList());