diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx index 040ca16c72..4244d7f702 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx @@ -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: {account.acct} }, - ), - 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 && ( - - ); - 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( - - - , - ); - } else if (relationship.followed_by) { - info.push( - - - , - ); - } else if (relationship.requested_by) { - info.push( - - - , - ); - } - - if (relationship.blocking) { - info.push( - - - , - ); - } - - if (relationship.muting) { - info.push( - - - , - ); - } - - if (relationship.domain_blocking) { - info.push( - - - , - ); - } - } - - if (relationship?.requested || relationship?.following) { - bellBtn = ( - - ); - } - - if ('share' in navigator) { - shareBtn = ( - - ); - } else { - shareBtn = ( - - ); - } - - const isMovedAndUnfollowedAccount = account.moved && !relationship?.following; - - if (!isMovedAndUnfollowedAccount) { - actionBtn = ( - - ); - } - - if (account.locked) { - lockedIcon = ( - - ); - } - - 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(); - } else if (account.group) { - badges.push(); - } - - account.roles.forEach((role) => { - badges.push( - {role.get('name')}} - domain={domain} - roleId={role.get('id')} - />, - ); - }); return (
@@ -776,15 +101,16 @@ export const AccountHeader: React.FC<{ inactive: !!account.moved, })} > - {!(suspended || hidden || account.moved) && - relationship?.requested_by && ( - - )} + {!suspendedOrHidden && !account.moved && relationship?.requested_by && ( + + )}
-
{info}
+ {me !== account.id && relationship && ( + + )} - {!(suspended || hidden) && ( + {!suspendedOrHidden && (
@@ -829,29 +153,37 @@ export const AccountHeader: React.FC<{ domain={domain ?? ''} isSelf={me === account.id} /> - {lockedIcon} + {account.locked && ( + + )}
- {badges.length > 0 && ( -
{badges}
- )} + - {account.id !== me && signedIn && !(suspended || hidden) && ( + {me && account.id !== me && !suspendedOrHidden && ( )} -
- {!hidden && actionBtn} - {!hidden && bellBtn} - {menu} -
+ - {!(suspended || hidden) && ( + {!suspendedOrHidden && (
- {account.id !== me && signedIn && ( + {me && account.id !== me && ( )} @@ -878,73 +210,26 @@ export const AccountHeader: React.FC<{ - +
-
- - - - - - - - - - - -
+
)} - {!(hideTabs || hidden) && ( -
- - - - - - - - - - - - -
- )} + {!hideTabs && !hidden && } {titleFromAccount(account)} diff --git a/app/javascript/mastodon/features/account_timeline/components/badges.tsx b/app/javascript/mastodon/features/account_timeline/components/badges.tsx new file mode 100644 index 0000000000..cc6c456e9c --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/components/badges.tsx @@ -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(); + } else if (account.group) { + badges.push(); + } + + const domain = account.acct.includes('@') + ? account.acct.split('@')[1] + : localDomain; + account.roles.forEach((role) => { + badges.push( + {role.get('name')}} + domain={domain} + roleId={role.get('id')} + />, + ); + }); + + if (!badges.length) { + return null; + } + + return
{badges}
; +}; diff --git a/app/javascript/mastodon/features/account_timeline/components/buttons.tsx b/app/javascript/mastodon/features/account_timeline/components/buttons.tsx new file mode 100644 index 0000000000..c998d1472c --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/components/buttons.tsx @@ -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 = ({ + accountId, + className, + noShare, +}) => { + const hidden = useAppSelector((state) => getAccountHidden(state, accountId)); + const me = useAppSelector((state) => state.meta.get('me') as string); + + return ( +
+ {!hidden && ( + + )} + {accountId !== me && } +
+ ); +}; + +const AccountButtonsOther: FC< + Pick +> = ({ 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 && ( + + )} + {isFollowing && ( + + )} + {!noShare && + ('share' in navigator ? ( + + ) : ( + + ))} + + ); +}; diff --git a/app/javascript/mastodon/features/account_timeline/components/info.tsx b/app/javascript/mastodon/features/account_timeline/components/info.tsx new file mode 100644 index 0000000000..bb99999c41 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/components/info.tsx @@ -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 ( +
+ {(relationship.followed_by || relationship.requested_by) && ( + + + + )} + {relationship.blocking && ( + + + + )} + {relationship.muting && ( + + + + )} + {relationship.domain_blocking && ( + + + + )} +
+ ); +}; + +const AccountInfoFollower: FC<{ relationship: Relationship }> = ({ + relationship, +}) => { + if ( + relationship.followed_by && + (relationship.following || relationship.requested) + ) { + return ( + + ); + } else if (relationship.followed_by) { + return ( + + ); + } else if (relationship.requested_by) { + return ( + + ); + } + return null; +}; diff --git a/app/javascript/mastodon/features/account_timeline/components/links.tsx b/app/javascript/mastodon/features/account_timeline/components/links.tsx new file mode 100644 index 0000000000..2e056e4e57 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/components/links.tsx @@ -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 ( +
+ + + + + + + + + + + +
+ ); +}; diff --git a/app/javascript/mastodon/features/account_timeline/components/menu.tsx b/app/javascript/mastodon/features/account_timeline/components/menu.tsx new file mode 100644 index 0000000000..ce98c61f76 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/components/menu.tsx @@ -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: {account.acct} }, + ), + 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 ( + + ); +}; diff --git a/app/javascript/mastodon/features/account_timeline/components/tabs.tsx b/app/javascript/mastodon/features/account_timeline/components/tabs.tsx new file mode 100644 index 0000000000..c08de1390e --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/components/tabs.tsx @@ -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 ( +
+ + + + + + + + + + + + +
+ ); +}; diff --git a/app/javascript/mastodon/hooks/useAccount.ts b/app/javascript/mastodon/hooks/useAccount.ts new file mode 100644 index 0000000000..277fa16931 --- /dev/null +++ b/app/javascript/mastodon/hooks/useAccount.ts @@ -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; +}