Account header split up (#37490)

This commit is contained in:
Echo
2026-01-15 14:45:30 +01:00
committed by GitHub
parent 2a6c084aa1
commit f2fb232e37
8 changed files with 776 additions and 762 deletions

View File

@@ -1,177 +1,35 @@
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { useIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Helmet } from 'react-helmet';
import { NavLink } from 'react-router-dom';
import { AccountBio } from '@/mastodon/components/account_bio';
import { AccountFields } from '@/mastodon/components/account_fields';
import { DisplayName } from '@/mastodon/components/display_name';
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
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,
removeAccountFromFollowers,
} 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 { CopyIconButton } from 'mastodon/components/copy_icon_button';
import {
FollowersCounter,
FollowingCounter,
StatusesCounter,
} from 'mastodon/components/counters';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { FollowButton } from 'mastodon/components/follow_button';
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { ShortNumber } from 'mastodon/components/short_number';
import { AccountNote } from 'mastodon/features/account/components/account_note';
import { DomainPill } from 'mastodon/features/account/components/domain_pill';
import 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 { MenuItem } from 'mastodon/models/dropdown_menu';
import {
PERMISSION_MANAGE_USERS,
PERMISSION_MANAGE_FEDERATION,
} from 'mastodon/permissions';
import { getAccountHidden } from 'mastodon/selectors/accounts';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { AccountBadges } from './badges';
import { AccountButtons } from './buttons';
import { FamiliarFollowers } from './familiar_followers';
import { AccountInfo } from './info';
import { AccountLinks } from './links';
import { MemorialNote } from './memorial_note';
import { MovedNote } from './moved_note';
const messages = defineMessages({
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',
},
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',
},
removeFromFollowers: {
id: 'account.remove_from_followers',
defaultMessage: 'Remove {name} from followers',
},
confirmRemoveFromFollowersTitle: {
id: 'confirmations.remove_from_followers.title',
defaultMessage: 'Remove follower?',
},
confirmRemoveFromFollowersMessage: {
id: 'confirmations.remove_from_followers.message',
defaultMessage:
'{name} will stop following you. Are you sure you want to proceed?',
},
confirmRemoveFromFollowersButton: {
id: 'confirmations.remove_from_followers.confirm',
defaultMessage: 'Remove follower',
},
});
import { AccountTabs } from './tabs';
const titleFromAccount = (account: Account) => {
const displayName = account.display_name;
@@ -191,149 +49,12 @@ export const AccountHeader: React.FC<{
}> = ({ 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 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 handleOpenAvatar = useCallback(
(e: React.MouseEvent) => {
if (e.button !== 0 || e.ctrlKey || e.metaKey) {
@@ -359,410 +80,14 @@ export const AccountHeader: React.FC<{
[dispatch, account],
);
const handleShare = useCallback(() => {
if (!account) {
return;
}
void navigator.share({
url: account.url,
});
}, [account]);
const suspended = account?.suspended;
const isRemote = account?.acct !== account?.username;
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
const menuItems = useMemo(() => {
const arr: MenuItem[] = [];
if (!account) {
return arr;
}
if (signedIn && !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 (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(
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?.followed_by) {
const handleRemoveFromFollowers = () => {
dispatch(
openModal({
modalType: 'CONFIRM',
modalProps: {
title: intl.formatMessage(
messages.confirmRemoveFromFollowersTitle,
),
message: intl.formatMessage(
messages.confirmRemoveFromFollowersMessage,
{ name: <strong>{account.acct}</strong> },
),
confirm: intl.formatMessage(
messages.confirmRemoveFromFollowersButton,
),
onConfirm: () => {
void dispatch(removeAccountFromFollowers({ accountId }));
},
},
}),
);
};
arr.push({
text: intl.formatMessage(messages.removeFromFollowers, {
name: account.username,
}),
action: handleRemoveFromFollowers,
dangerous: true,
});
}
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 (
(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;
}, [
dispatch,
accountId,
account,
relationship,
permissions,
isRemote,
remoteDomain,
intl,
signedIn,
handleAddToList,
handleBlock,
handleBlockDomain,
handleChangeLanguages,
handleDirect,
handleEndorseToggle,
handleMention,
handleMute,
handleReblogToggle,
handleReport,
handleUnblockDomain,
]);
const menu = accountId !== me && (
<Dropdown
disabled={menuItems.length === 0}
items={menuItems}
icon='ellipsis-v'
iconComponent={MoreHorizIcon}
/>
);
if (!account) {
return null;
}
let actionBtn: React.ReactNode,
bellBtn: React.ReactNode,
lockedIcon: React.ReactNode,
shareBtn: React.ReactNode;
const info: React.ReactNode[] = [];
if (me !== account.id && relationship) {
if (
relationship.followed_by &&
(relationship.following || relationship.requested)
) {
info.push(
<span key='mutual' className='relationship-tag'>
<FormattedMessage
id='account.mutual'
defaultMessage='You follow each other'
/>
</span>,
);
} else if (relationship.followed_by) {
info.push(
<span key='followed_by' className='relationship-tag'>
<FormattedMessage
id='account.follows_you'
defaultMessage='Follows you'
/>
</span>,
);
} else if (relationship.requested_by) {
info.push(
<span key='requested_by' className='relationship-tag'>
<FormattedMessage
id='account.requests_to_follow_you'
defaultMessage='Requests to follow you'
/>
</span>,
);
}
if (relationship.blocking) {
info.push(
<span key='blocking' className='relationship-tag'>
<FormattedMessage id='account.blocking' defaultMessage='Blocking' />
</span>,
);
}
if (relationship.muting) {
info.push(
<span key='muting' className='relationship-tag'>
<FormattedMessage id='account.muting' defaultMessage='Muting' />
</span>,
);
}
if (relationship.domain_blocking) {
info.push(
<span key='domain_blocking' className='relationship-tag'>
<FormattedMessage
id='account.domain_blocking'
defaultMessage='Blocking domain'
/>
</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}
/>
);
}
const isMovedAndUnfollowedAccount = account.moved && !relationship?.following;
if (!isMovedAndUnfollowedAccount) {
actionBtn = (
<FollowButton
accountId={accountId}
className='account__header__follow-button'
labelLength='long'
/>
);
}
if (account.locked) {
lockedIcon = (
<Icon
id='lock'
icon={LockIcon}
aria-label={intl.formatMessage(messages.account_locked)}
/>
);
}
const fields = account.fields;
const suspendedOrHidden = hidden || account.suspended;
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.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'>
@@ -776,15 +101,16 @@ export const AccountHeader: React.FC<{
inactive: !!account.moved,
})}
>
{!(suspended || hidden || account.moved) &&
relationship?.requested_by && (
<FollowRequestNoteContainer account={account} />
)}
{!suspendedOrHidden && !account.moved && relationship?.requested_by && (
<FollowRequestNoteContainer account={account} />
)}
<div className='account__header__image'>
<div className='account__header__info'>{info}</div>
{me !== account.id && relationship && (
<AccountInfo relationship={relationship} />
)}
{!(suspended || hidden) && (
{!suspendedOrHidden && (
<img
src={autoPlayGif ? account.header : account.header_static}
alt=''
@@ -803,17 +129,15 @@ export const AccountHeader: React.FC<{
onClick={handleOpenAvatar}
>
<Avatar
account={suspended || hidden ? undefined : account}
account={suspendedOrHidden ? undefined : account}
size={92}
/>
</a>
<div className='account__header__buttons account__header__buttons--desktop'>
{!hidden && actionBtn}
{!hidden && bellBtn}
{!hidden && shareBtn}
{menu}
</div>
<AccountButtons
accountId={accountId}
className='account__header__buttons--desktop'
/>
</div>
<div className='account__header__tabs__name'>
@@ -829,29 +153,37 @@ export const AccountHeader: React.FC<{
domain={domain ?? ''}
isSelf={me === account.id}
/>
{lockedIcon}
{account.locked && (
<Icon
id='lock'
icon={LockIcon}
aria-label={intl.formatMessage({
id: 'account.locked_info',
defaultMessage:
'This account privacy status is set to locked. The owner manually reviews who can follow them.',
})}
/>
)}
</small>
</h1>
</div>
{badges.length > 0 && (
<div className='account__header__badges'>{badges}</div>
)}
<AccountBadges accountId={accountId} />
{account.id !== me && signedIn && !(suspended || hidden) && (
{me && account.id !== me && !suspendedOrHidden && (
<FamiliarFollowers accountId={accountId} />
)}
<div className='account__header__buttons account__header__buttons--mobile'>
{!hidden && actionBtn}
{!hidden && bellBtn}
{menu}
</div>
<AccountButtons
className='account__header__buttons--mobile'
accountId={accountId}
noShare
/>
{!(suspended || hidden) && (
{!suspendedOrHidden && (
<div className='account__header__extra'>
<div className='account__header__bio'>
{account.id !== me && signedIn && (
{me && account.id !== me && (
<AccountNote accountId={accountId} />
)}
@@ -878,73 +210,26 @@ export const AccountHeader: React.FC<{
</dd>
</dl>
<AccountFields fields={fields} emojis={account.emojis} />
<AccountFields
fields={account.fields}
emojis={account.emojis}
/>
</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>
<AccountLinks accountId={accountId} />
</div>
)}
</div>
</AnimateEmojiProvider>
{!(hideTabs || hidden) && (
<div className='account__section-headline'>
<NavLink exact to={`/@${account.acct}/featured`}>
<FormattedMessage id='account.featured' defaultMessage='Featured' />
</NavLink>
<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>
)}
{!hideTabs && !hidden && <AccountTabs acct={account.acct} />}
<Helmet>
<title>{titleFromAccount(account)}</title>
<meta
name='robots'
content={isLocal && isIndexable ? 'all' : 'noindex'}
content={isLocal && !account.noindex ? 'all' : 'noindex'}
/>
<link rel='canonical' href={account.url} />
</Helmet>

View File

@@ -0,0 +1,43 @@
import type { FC } from 'react';
import { AutomatedBadge, Badge, GroupBadge } from '@/mastodon/components/badge';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useAppSelector } from '@/mastodon/store';
export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
const account = useAccount(accountId);
const localDomain = useAppSelector(
(state) => state.meta.get('domain') as string,
);
const badges = [];
if (!account) {
return null;
}
if (account.bot) {
badges.push(<AutomatedBadge key='bot-badge' />);
} else if (account.group) {
badges.push(<GroupBadge key='group-badge' />);
}
const domain = account.acct.includes('@')
? account.acct.split('@')[1]
: localDomain;
account.roles.forEach((role) => {
badges.push(
<Badge
key={`role-badge-${role.get('id')}`}
label={<span>{role.get('name')}</span>}
domain={domain}
roleId={role.get('id')}
/>,
);
});
if (!badges.length) {
return null;
}
return <div className='account__header__badges'>{badges}</div>;
};

View File

@@ -0,0 +1,134 @@
import { useCallback } from 'react';
import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { followAccount } from '@/mastodon/actions/accounts';
import { CopyIconButton } from '@/mastodon/components/copy_icon_button';
import { FollowButton } from '@/mastodon/components/follow_button';
import { IconButton } from '@/mastodon/components/icon_button';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { getAccountHidden } from '@/mastodon/selectors/accounts';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
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 { AccountMenu } from './menu';
const messages = defineMessages({
enableNotifications: {
id: 'account.enable_notifications',
defaultMessage: 'Notify me when @{name} posts',
},
disableNotifications: {
id: 'account.disable_notifications',
defaultMessage: 'Stop notifying me when @{name} posts',
},
share: { id: 'account.share', defaultMessage: "Share @{name}'s profile" },
copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' },
});
interface AccountButtonsProps {
accountId: string;
className?: string;
noShare?: boolean;
}
export const AccountButtons: FC<AccountButtonsProps> = ({
accountId,
className,
noShare,
}) => {
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
const me = useAppSelector((state) => state.meta.get('me') as string);
return (
<div className={classNames('account__header__buttons', className)}>
{!hidden && (
<AccountButtonsOther accountId={accountId} noShare={noShare} />
)}
{accountId !== me && <AccountMenu accountId={accountId} />}
</div>
);
};
const AccountButtonsOther: FC<
Pick<AccountButtonsProps, 'accountId' | 'noShare'>
> = ({ accountId, noShare }) => {
const intl = useIntl();
const account = useAccount(accountId);
const relationship = useAppSelector((state) =>
state.relationships.get(accountId),
);
const dispatch = useAppDispatch();
const handleNotifyToggle = useCallback(() => {
if (account) {
dispatch(followAccount(account.id, { notify: !relationship?.notifying }));
}
}, [dispatch, account, relationship]);
const accountUrl = account?.url;
const handleShare = useCallback(() => {
if (accountUrl) {
void navigator.share({
url: accountUrl,
});
}
}, [accountUrl]);
if (!account) {
return null;
}
const isMovedAndUnfollowedAccount = account.moved && !relationship?.following;
const isFollowing = relationship?.requested || relationship?.following;
return (
<>
{!isMovedAndUnfollowedAccount && (
<FollowButton
accountId={accountId}
className='account__header__follow-button'
labelLength='long'
/>
)}
{isFollowing && (
<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}
/>
)}
{!noShare &&
('share' in navigator ? (
<IconButton
className='optional'
icon=''
iconComponent={ShareIcon}
title={intl.formatMessage(messages.share, {
name: account.username,
})}
onClick={handleShare}
/>
) : (
<CopyIconButton
className='optional'
title={intl.formatMessage(messages.copy)}
value={account.url}
/>
))}
</>
);
};

View File

@@ -0,0 +1,68 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import type { Relationship } from '@/mastodon/models/relationship';
export const AccountInfo: FC<{ relationship?: Relationship }> = ({
relationship,
}) => {
if (!relationship) {
return null;
}
return (
<div className='account__header__info'>
{(relationship.followed_by || relationship.requested_by) && (
<span className='relationship-tag'>
<AccountInfoFollower relationship={relationship} />
</span>
)}
{relationship.blocking && (
<span className='relationship-tag'>
<FormattedMessage id='account.blocking' defaultMessage='Blocking' />
</span>
)}
{relationship.muting && (
<span key='muting' className='relationship-tag'>
<FormattedMessage id='account.muting' defaultMessage='Muting' />
</span>
)}
{relationship.domain_blocking && (
<span key='domain_blocking' className='relationship-tag'>
<FormattedMessage
id='account.domain_blocking'
defaultMessage='Blocking domain'
/>
</span>
)}
</div>
);
};
const AccountInfoFollower: FC<{ relationship: Relationship }> = ({
relationship,
}) => {
if (
relationship.followed_by &&
(relationship.following || relationship.requested)
) {
return (
<FormattedMessage
id='account.mutual'
defaultMessage='You follow each other'
/>
);
} else if (relationship.followed_by) {
return (
<FormattedMessage id='account.follows_you' defaultMessage='Follows you' />
);
} else if (relationship.requested_by) {
return (
<FormattedMessage
id='account.requests_to_follow_you'
defaultMessage='Requests to follow you'
/>
);
}
return null;
};

View File

@@ -0,0 +1,58 @@
import type { FC } from 'react';
import { useIntl } from 'react-intl';
import { NavLink } from 'react-router-dom';
import {
FollowersCounter,
FollowingCounter,
StatusesCounter,
} from '@/mastodon/components/counters';
import { ShortNumber } from '@/mastodon/components/short_number';
import { useAccount } from '@/mastodon/hooks/useAccount';
export const AccountLinks: FC<{ accountId: string }> = ({ accountId }) => {
const intl = useIntl();
const account = useAccount(accountId);
if (!account) {
return null;
}
return (
<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>
);
};

View File

@@ -0,0 +1,373 @@
import { useMemo } from 'react';
import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import {
blockAccount,
followAccount,
pinAccount,
unblockAccount,
unmuteAccount,
unpinAccount,
} from '@/mastodon/actions/accounts';
import { removeAccountFromFollowers } from '@/mastodon/actions/accounts_typed';
import { directCompose, mentionCompose } 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 { Dropdown } from '@/mastodon/components/dropdown_menu';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useIdentity } from '@/mastodon/identity_context';
import type { MenuItem } from '@/mastodon/models/dropdown_menu';
import {
PERMISSION_MANAGE_FEDERATION,
PERMISSION_MANAGE_USERS,
} from '@/mastodon/permissions';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
const messages = defineMessages({
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
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}' },
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}',
},
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',
},
removeFromFollowers: {
id: 'account.remove_from_followers',
defaultMessage: 'Remove {name} from followers',
},
confirmRemoveFromFollowersTitle: {
id: 'confirmations.remove_from_followers.title',
defaultMessage: 'Remove follower?',
},
confirmRemoveFromFollowersMessage: {
id: 'confirmations.remove_from_followers.message',
defaultMessage:
'{name} will stop following you. Are you sure you want to proceed?',
},
confirmRemoveFromFollowersButton: {
id: 'confirmations.remove_from_followers.confirm',
defaultMessage: 'Remove follower',
},
});
export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
const intl = useIntl();
const { signedIn, permissions } = useIdentity();
const account = useAccount(accountId);
const relationship = useAppSelector((state) =>
state.relationships.get(accountId),
);
const dispatch = useAppDispatch();
const menuItems = useMemo(() => {
const arr: MenuItem[] = [];
if (!account) {
return arr;
}
const isRemote = account.acct !== account.username;
if (signedIn && !account.suspended) {
arr.push({
text: intl.formatMessage(messages.mention, {
name: account.username,
}),
action: () => {
dispatch(mentionCompose(account));
},
});
arr.push({
text: intl.formatMessage(messages.direct, {
name: account.username,
}),
action: () => {
dispatch(directCompose(account));
},
});
arr.push(null);
}
if (isRemote) {
arr.push({
text: intl.formatMessage(messages.openOriginalPage),
href: account.url,
});
arr.push(null);
}
if (!signedIn) {
return arr;
}
if (relationship?.following) {
if (!relationship.muting) {
if (relationship.showing_reblogs) {
arr.push({
text: intl.formatMessage(messages.hideReblogs, {
name: account.username,
}),
action: () => {
dispatch(followAccount(account.id, { reblogs: false }));
},
});
} else {
arr.push({
text: intl.formatMessage(messages.showReblogs, {
name: account.username,
}),
action: () => {
dispatch(followAccount(account.id, { reblogs: true }));
},
});
}
arr.push({
text: intl.formatMessage(messages.languages),
action: () => {
dispatch(
openModal({
modalType: 'SUBSCRIBED_LANGUAGES',
modalProps: {
accountId: account.id,
},
}),
);
},
});
arr.push(null);
}
arr.push({
text: intl.formatMessage(
relationship.endorsed ? messages.unendorse : messages.endorse,
),
action: () => {
if (relationship.endorsed) {
dispatch(unpinAccount(account.id));
} else {
dispatch(pinAccount(account.id));
}
},
});
arr.push({
text: intl.formatMessage(messages.add_or_remove_from_list),
action: () => {
dispatch(
openModal({
modalType: 'LIST_ADDER',
modalProps: {
accountId: account.id,
},
}),
);
},
});
arr.push(null);
}
if (relationship?.followed_by) {
const handleRemoveFromFollowers = () => {
dispatch(
openModal({
modalType: 'CONFIRM',
modalProps: {
title: intl.formatMessage(
messages.confirmRemoveFromFollowersTitle,
),
message: intl.formatMessage(
messages.confirmRemoveFromFollowersMessage,
{ name: <strong>{account.acct}</strong> },
),
confirm: intl.formatMessage(
messages.confirmRemoveFromFollowersButton,
),
onConfirm: () => {
void dispatch(
removeAccountFromFollowers({ accountId: account.id }),
);
},
},
}),
);
};
arr.push({
text: intl.formatMessage(messages.removeFromFollowers, {
name: account.username,
}),
action: handleRemoveFromFollowers,
dangerous: true,
});
}
if (relationship?.muting) {
arr.push({
text: intl.formatMessage(messages.unmute, {
name: account.username,
}),
action: () => {
dispatch(unmuteAccount(account.id));
},
});
} else {
arr.push({
text: intl.formatMessage(messages.mute, {
name: account.username,
}),
action: () => {
dispatch(initMuteModal(account));
},
dangerous: true,
});
}
if (relationship?.blocking) {
arr.push({
text: intl.formatMessage(messages.unblock, {
name: account.username,
}),
action: () => {
dispatch(unblockAccount(account.id));
},
});
} else {
arr.push({
text: intl.formatMessage(messages.block, {
name: account.username,
}),
action: () => {
dispatch(blockAccount(account.id));
},
dangerous: true,
});
}
if (!account.suspended) {
arr.push({
text: intl.formatMessage(messages.report, {
name: account.username,
}),
action: () => {
dispatch(initReport(account));
},
dangerous: true,
});
}
const remoteDomain = isRemote ? account.acct.split('@')[1] : null;
if (remoteDomain) {
arr.push(null);
if (relationship?.domain_blocking) {
arr.push({
text: intl.formatMessage(messages.unblockDomain, {
domain: remoteDomain,
}),
action: () => {
dispatch(unblockDomain(remoteDomain));
},
});
} else {
arr.push({
text: intl.formatMessage(messages.blockDomain, {
domain: remoteDomain,
}),
action: () => {
dispatch(initDomainBlockModal(account));
},
dangerous: true,
});
}
}
if (
(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, signedIn, permissions, intl, relationship, dispatch]);
return (
<Dropdown
disabled={menuItems.length === 0}
items={menuItems}
icon='ellipsis-v'
iconComponent={MoreHorizIcon}
/>
);
};

View File

@@ -0,0 +1,27 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { NavLink } from 'react-router-dom';
export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
return (
<div className='account__section-headline'>
<NavLink exact to={`/@${acct}/featured`}>
<FormattedMessage id='account.featured' defaultMessage='Featured' />
</NavLink>
<NavLink exact to={`/@${acct}`}>
<FormattedMessage id='account.posts' defaultMessage='Posts' />
</NavLink>
<NavLink exact to={`/@${acct}/with_replies`}>
<FormattedMessage
id='account.posts_with_replies'
defaultMessage='Posts and replies'
/>
</NavLink>
<NavLink exact to={`/@${acct}/media`}>
<FormattedMessage id='account.media' defaultMessage='Media' />
</NavLink>
</div>
);
};

View File

@@ -0,0 +1,26 @@
import { useEffect } from 'react';
import { fetchAccount } from '../actions/accounts';
import { createAppSelector, useAppDispatch, useAppSelector } from '../store';
export const accountSelector = createAppSelector(
[
(state) => state.accounts,
(_, accountId: string | null | undefined) => accountId,
],
(accounts, accountId) => (accountId ? accounts.get(accountId) : undefined),
);
export function useAccount(accountId: string | null | undefined) {
const account = useAppSelector((state) => accountSelector(state, accountId));
const dispatch = useAppDispatch();
const accountInStore = !!account;
useEffect(() => {
if (accountId && !accountInStore) {
dispatch(fetchAccount(accountId));
}
}, [accountId, accountInStore, dispatch]);
return account;
}