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