diff --git a/app/javascript/flavours/glitch/components/account_bio.tsx b/app/javascript/flavours/glitch/components/account_bio.tsx new file mode 100644 index 0000000000..567a2374c2 --- /dev/null +++ b/app/javascript/flavours/glitch/components/account_bio.tsx @@ -0,0 +1,20 @@ +import { useLinks } from 'flavours/glitch/hooks/useLinks'; + +export const AccountBio: React.FC<{ + note: string; + className: string; +}> = ({ note, className }) => { + const handleClick = useLinks(); + + if (note.length === 0 || note === '

') { + return null; + } + + return ( +
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/account_fields.tsx b/app/javascript/flavours/glitch/components/account_fields.tsx new file mode 100644 index 0000000000..768eb1fa4b --- /dev/null +++ b/app/javascript/flavours/glitch/components/account_fields.tsx @@ -0,0 +1,42 @@ +import classNames from 'classnames'; + +import CheckIcon from '@/material-icons/400-24px/check.svg?react'; +import { Icon } from 'flavours/glitch/components/icon'; +import { useLinks } from 'flavours/glitch/hooks/useLinks'; +import type { Account } from 'flavours/glitch/models/account'; + +export const AccountFields: React.FC<{ + fields: Account['fields']; + limit: number; +}> = ({ fields, limit = -1 }) => { + const handleClick = useLinks(); + + if (fields.size === 0) { + return null; + } + + return ( +
+ {fields.take(limit).map((pair, i) => ( +
+
+ +
+ {pair.get('verified_at') && ( + + )} + +
+
+ ))} +
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/follow_button.tsx b/app/javascript/flavours/glitch/components/follow_button.tsx new file mode 100644 index 0000000000..664ac86000 --- /dev/null +++ b/app/javascript/flavours/glitch/components/follow_button.tsx @@ -0,0 +1,90 @@ +import { useCallback, useEffect } from 'react'; + +import { useIntl, defineMessages } from 'react-intl'; + +import { + fetchRelationships, + followAccount, + unfollowAccount, +} from 'flavours/glitch/actions/accounts'; +import { Button } from 'flavours/glitch/components/button'; +import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; +import { me } from 'flavours/glitch/initial_state'; +import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; + +const messages = defineMessages({ + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' }, + cancel_follow_request: { + id: 'account.cancel_follow_request', + defaultMessage: 'Withdraw follow request', + }, + edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, +}); + +export const FollowButton: React.FC<{ + accountId: string; +}> = ({ accountId }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const relationship = useAppSelector((state) => + state.relationships.get(accountId), + ); + const following = relationship?.following || relationship?.requested; + + useEffect(() => { + dispatch(fetchRelationships([accountId])); + }, [dispatch, accountId]); + + const handleClick = useCallback(() => { + if (!relationship) return; + if (accountId === me) { + return; + } else if (relationship.following || relationship.requested) { + dispatch(unfollowAccount(accountId)); + } else { + dispatch(followAccount(accountId)); + } + }, [dispatch, accountId, relationship]); + + let label; + + if (accountId === me) { + label = intl.formatMessage(messages.edit_profile); + } else if (!relationship) { + label = ; + } else if (relationship.requested) { + label = intl.formatMessage(messages.cancel_follow_request); + } else if (!relationship.following && relationship.followed_by) { + label = intl.formatMessage(messages.followBack); + } else if (relationship.following) { + label = intl.formatMessage(messages.unfollow); + } else { + label = intl.formatMessage(messages.follow); + } + + if (accountId === me) { + return ( + + {label} + + ); + } + + return ( + + ); +}; diff --git a/app/javascript/flavours/glitch/components/hover_card_account.tsx b/app/javascript/flavours/glitch/components/hover_card_account.tsx new file mode 100644 index 0000000000..a62128e17b --- /dev/null +++ b/app/javascript/flavours/glitch/components/hover_card_account.tsx @@ -0,0 +1,78 @@ +import { useEffect, forwardRef } from 'react'; + +import classNames from 'classnames'; + +import { fetchAccount } from 'flavours/glitch/actions/accounts'; +import { AccountBio } from 'flavours/glitch/components/account_bio'; +import { AccountFields } from 'flavours/glitch/components/account_fields'; +import { Avatar } from 'flavours/glitch/components/avatar'; +import { FollowersCounter } from 'flavours/glitch/components/counters'; +import { DisplayName } from 'flavours/glitch/components/display_name'; +import { FollowButton } from 'flavours/glitch/components/follow_button'; +import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; +import { Permalink } from 'flavours/glitch/components/permalink'; +import { ShortNumber } from 'flavours/glitch/components/short_number'; +import { domain } from 'flavours/glitch/initial_state'; +import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; + +export const HoverCardAccount = forwardRef< + HTMLDivElement, + { accountId: string } +>(({ accountId }, ref) => { + const dispatch = useAppDispatch(); + + const account = useAppSelector((state) => + accountId ? state.accounts.get(accountId) : undefined, + ); + + useEffect(() => { + if (accountId && !account) { + dispatch(fetchAccount(accountId)); + } + }, [dispatch, accountId, account]); + + return ( + + ); +}); + +HoverCardAccount.displayName = 'HoverCardAccount'; diff --git a/app/javascript/flavours/glitch/components/hover_card_controller.tsx b/app/javascript/flavours/glitch/components/hover_card_controller.tsx new file mode 100644 index 0000000000..6e11d28381 --- /dev/null +++ b/app/javascript/flavours/glitch/components/hover_card_controller.tsx @@ -0,0 +1,117 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; + +import { useLocation } from 'react-router-dom'; + +import Overlay from 'react-overlays/Overlay'; +import type { + OffsetValue, + UsePopperOptions, +} from 'react-overlays/esm/usePopper'; + +import { HoverCardAccount } from 'flavours/glitch/components/hover_card_account'; +import { useTimeout } from 'flavours/glitch/hooks/useTimeout'; + +const offset = [-12, 4] as OffsetValue; +const enterDelay = 650; +const leaveDelay = 250; +const popperConfig = { strategy: 'fixed' } as UsePopperOptions; + +const isHoverCardAnchor = (element: HTMLElement) => + element.matches('[data-hover-card-account]'); + +export const HoverCardController: React.FC = () => { + const [open, setOpen] = useState(false); + const [accountId, setAccountId] = useState(); + const [anchor, setAnchor] = useState(null); + const cardRef = useRef(null); + const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout(); + const [setEnterTimeout, cancelEnterTimeout] = useTimeout(); + const location = useLocation(); + + const handleAnchorMouseEnter = useCallback( + (e: MouseEvent) => { + const { target } = e; + + if (target instanceof HTMLElement && isHoverCardAnchor(target)) { + cancelLeaveTimeout(); + + setEnterTimeout(() => { + target.setAttribute('aria-describedby', 'hover-card'); + setAnchor(target); + setOpen(true); + setAccountId( + target.getAttribute('data-hover-card-account') ?? undefined, + ); + }, enterDelay); + } + + if (target === cardRef.current?.parentNode) { + cancelLeaveTimeout(); + } + }, + [cancelLeaveTimeout, setEnterTimeout, setOpen, setAccountId, setAnchor], + ); + + const handleAnchorMouseLeave = useCallback( + (e: MouseEvent) => { + if (e.target === anchor || e.target === cardRef.current?.parentNode) { + cancelEnterTimeout(); + + setLeaveTimeout(() => { + anchor?.removeAttribute('aria-describedby'); + setOpen(false); + setAnchor(null); + }, leaveDelay); + } + }, + [cancelEnterTimeout, setLeaveTimeout, setOpen, setAnchor, anchor], + ); + + const handleClose = useCallback(() => { + cancelEnterTimeout(); + cancelLeaveTimeout(); + setOpen(false); + setAnchor(null); + }, [cancelEnterTimeout, cancelLeaveTimeout, setOpen, setAnchor]); + + useEffect(() => { + handleClose(); + }, [handleClose, location]); + + useEffect(() => { + document.body.addEventListener('mouseenter', handleAnchorMouseEnter, { + passive: true, + capture: true, + }); + document.body.addEventListener('mouseleave', handleAnchorMouseLeave, { + passive: true, + capture: true, + }); + + return () => { + document.body.removeEventListener('mouseenter', handleAnchorMouseEnter); + document.body.removeEventListener('mouseleave', handleAnchorMouseLeave); + }; + }, [handleAnchorMouseEnter, handleAnchorMouseLeave]); + + if (!accountId) return null; + + return ( + + {({ props }) => ( +
+ +
+ )} +
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/status_content.jsx b/app/javascript/flavours/glitch/components/status_content.jsx index 24da69cdf2..c28f85eb72 100644 --- a/app/javascript/flavours/glitch/components/status_content.jsx +++ b/app/javascript/flavours/glitch/components/status_content.jsx @@ -181,7 +181,8 @@ class StatusContent extends PureComponent { if (mention) { link.addEventListener('click', this.onMentionClick.bind(this, mention), false); - link.setAttribute('title', `@${mention.get('acct')}`); + link.removeAttribute('title'); + link.setAttribute('data-hover-card-account', mention.get('id')); if (rewriteMentions !== 'no') { while (link.firstChild) link.removeChild(link.firstChild); link.appendChild(document.createTextNode('@')); diff --git a/app/javascript/flavours/glitch/components/status_header.jsx b/app/javascript/flavours/glitch/components/status_header.jsx index 692dca5c7b..ee4573659c 100644 --- a/app/javascript/flavours/glitch/components/status_header.jsx +++ b/app/javascript/flavours/glitch/components/status_header.jsx @@ -51,6 +51,7 @@ export default class StatusHeader extends PureComponent { target='_blank' onClick={this.handleAccountClick} rel='noopener noreferrer' + data-hover-card-account={status.getIn(['account', 'id'])} >
{statusAvatar} diff --git a/app/javascript/flavours/glitch/components/status_prepend.jsx b/app/javascript/flavours/glitch/components/status_prepend.jsx index 41902e60ba..e3bb554e2a 100644 --- a/app/javascript/flavours/glitch/components/status_prepend.jsx +++ b/app/javascript/flavours/glitch/components/status_prepend.jsx @@ -38,6 +38,7 @@ export default class StatusPrepend extends PureComponent { onClick={this.handleClick} href={account.get('url')} className='status__display-name' + data-hover-card-account={account.get('id')} > { } return ( - + diff --git a/app/javascript/flavours/glitch/features/explore/components/card.jsx b/app/javascript/flavours/glitch/features/explore/components/card.jsx index 85faa92e57..4612d25e21 100644 --- a/app/javascript/flavours/glitch/features/explore/components/card.jsx +++ b/app/javascript/flavours/glitch/features/explore/components/card.jsx @@ -8,34 +8,21 @@ import { Link } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; import CloseIcon from '@/material-icons/400-24px/close.svg?react'; -import { followAccount, unfollowAccount } from 'flavours/glitch/actions/accounts'; import { dismissSuggestion } from 'flavours/glitch/actions/suggestions'; import { Avatar } from 'flavours/glitch/components/avatar'; -import { Button } from 'flavours/glitch/components/button'; import { DisplayName } from 'flavours/glitch/components/display_name'; +import { FollowButton } from 'flavours/glitch/components/follow_button'; import { IconButton } from 'flavours/glitch/components/icon_button'; import { domain } from 'flavours/glitch/initial_state'; const messages = defineMessages({ - follow: { id: 'account.follow', defaultMessage: 'Follow' }, - unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" }, }); export const Card = ({ id, source }) => { const intl = useIntl(); const account = useSelector(state => state.getIn(['accounts', id])); - const relationship = useSelector(state => state.getIn(['relationships', id])); const dispatch = useDispatch(); - const following = relationship?.get('following') ?? relationship?.get('requested'); - - const handleFollow = useCallback(() => { - if (following) { - dispatch(unfollowAccount(id)); - } else { - dispatch(followAccount(id)); - } - }, [id, following, dispatch]); const handleDismiss = useCallback(() => { dispatch(dismissSuggestion(id)); @@ -74,7 +61,7 @@ export const Card = ({ id, source }) => {
-
diff --git a/app/javascript/flavours/glitch/features/home_timeline/components/inline_follow_suggestions.jsx b/app/javascript/flavours/glitch/features/home_timeline/components/inline_follow_suggestions.jsx index 97b64a09b1..4e727a63ed 100644 --- a/app/javascript/flavours/glitch/features/home_timeline/components/inline_follow_suggestions.jsx +++ b/app/javascript/flavours/glitch/features/home_timeline/components/inline_follow_suggestions.jsx @@ -12,12 +12,11 @@ import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import InfoIcon from '@/material-icons/400-24px/info.svg?react'; -import { followAccount, unfollowAccount } from 'flavours/glitch/actions/accounts'; import { changeSetting } from 'flavours/glitch/actions/settings'; import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions'; import { Avatar } from 'flavours/glitch/components/avatar'; -import { Button } from 'flavours/glitch/components/button'; import { DisplayName } from 'flavours/glitch/components/display_name'; +import { FollowButton } from 'flavours/glitch/components/follow_button'; import { Icon } from 'flavours/glitch/components/icon'; import { IconButton } from 'flavours/glitch/components/icon_button'; import { VerifiedBadge } from 'flavours/glitch/components/verified_badge'; @@ -79,18 +78,8 @@ Source.propTypes = { const Card = ({ id, sources }) => { const intl = useIntl(); const account = useSelector(state => state.getIn(['accounts', id])); - const relationship = useSelector(state => state.getIn(['relationships', id])); const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at')); const dispatch = useDispatch(); - const following = relationship?.get('following') ?? relationship?.get('requested'); - - const handleFollow = useCallback(() => { - if (following) { - dispatch(unfollowAccount(id)); - } else { - dispatch(followAccount(id)); - } - }, [id, following, dispatch]); const handleDismiss = useCallback(() => { dispatch(dismissSuggestion(id)); @@ -109,7 +98,7 @@ const Card = ({ id, sources }) => { {firstVerifiedField ? : } -