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
('.custom-emoji')
+ .forEach((emoji) => {
+ emoji.src = emoji.getAttribute('data-original') ?? '';
+ });
+ },
+ [],
+ );
+
+ const handleMouseLeave = useCallback(
+ ({ currentTarget }: React.MouseEvent) => {
+ if (autoPlayGif) {
+ return;
+ }
+
+ currentTarget
+ .querySelectorAll('.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(
+
+
+ ,
+ );
+ }
+
+ if (me !== account.id && relationship?.muting) {
+ info.push(
+
+
+ ,
+ );
+ } else if (me !== account.id && relationship?.domain_blocking) {
+ info.push(
+
+
+ ,
+ );
+ }
+
+ if (relationship?.requested || relationship?.following) {
+ bellBtn = (
+
+ );
+ }
+
+ if ('share' in navigator) {
+ shareBtn = (
+
+ );
+ } else {
+ shareBtn = (
+
+ );
+ }
+
+ if (me !== account.id) {
+ if (signedIn && !relationship) {
+ // Wait until the relationship is loaded
+ actionBtn = (
+
+ );
+ } else if (!relationship?.blocking) {
+ actionBtn = (
+
+ );
+ } else {
+ actionBtn = (
+
+ );
+ }
+ } else {
+ actionBtn = (
+
+ );
+ }
+
+ if (account.moved && !relationship?.following) {
+ actionBtn = '';
+ }
+
+ if (account.locked) {
+ lockedIcon = (
+
+ );
+ }
+
+ 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();
+ } else if (account.group) {
+ badges.push();
+ }
+
+ account.get('roles', []).forEach((role) => {
+ badges.push(
+ {role.get('name')}}
+ domain={domain}
+ roleId={role.get('id')}
+ />,
+ );
+ });
+
+ return (
+
+ {!hidden && account.memorial &&
}
+ {!hidden && account.moved && (
+
+ )}
+
+
+ {!(suspended || hidden || account.moved) &&
+ relationship?.requested_by && (
+
+ )}
+
+
+
{info}
+
+ {!(suspended || hidden) && (
+

+ )}
+
+
+
+
+
+
+
+
+
+ {!hidden && bellBtn}
+ {!hidden && shareBtn}
+
+ {!hidden && actionBtn}
+
+
+
+
+
+
+
+
+ @{username}
+ @{domain}
+
+
+ {lockedIcon}
+
+
+
+
+ {badges.length > 0 && (
+
{badges}
+ )}
+
+ {!(suspended || hidden) && (
+
+
+ {account.id !== me && signedIn && (
+
+ )}
+
+ {account.note.length > 0 && account.note !== '
' && (
+
+ )}
+
+
+
+ -
+
+
+ -
+ {intl.formatDate(account.created_at, {
+ year: 'numeric',
+ month: 'short',
+ day: '2-digit',
+ })}
+
+
+
+ {fields.map((pair, i) => (
+
+
+
+ -
+ {pair.verified_at && (
+
+
+
+ )}{' '}
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ {!(hideTabs || hidden) && (
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ {titleFromAccount(account)}
+
+
+
+
+ );
+};
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 (
-
- {(!hidden && account.get('memorial')) &&
}
- {(!hidden && account.get('moved')) &&
}
-
-
-
- {!(hideTabs || hidden) && (
-
-
-
-
-
- )}
-
- );
- }
-
-}
-
-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 {
}
+ prepend={}
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={}
+ prepend={}
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={}
+ prepend={}
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());