Add hover cards in web UI (#30754)
Co-authored-by: Renaud Chaput <renchap@gmail.com>
This commit is contained in:
parent
863c470a2b
commit
e89317d4c1
18 changed files with 631 additions and 42 deletions
20
app/javascript/mastodon/components/account_bio.tsx
Normal file
20
app/javascript/mastodon/components/account_bio.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { useLinks } from 'mastodon/../hooks/useLinks';
|
||||
|
||||
export const AccountBio: React.FC<{
|
||||
note: string;
|
||||
className: string;
|
||||
}> = ({ note, className }) => {
|
||||
const handleClick = useLinks();
|
||||
|
||||
if (note.length === 0 || note === '<p></p>') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${className} translate`}
|
||||
dangerouslySetInnerHTML={{ __html: note }}
|
||||
onClickCapture={handleClick}
|
||||
/>
|
||||
);
|
||||
};
|
42
app/javascript/mastodon/components/account_fields.tsx
Normal file
42
app/javascript/mastodon/components/account_fields.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import classNames from 'classnames';
|
||||
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import { useLinks } from 'mastodon/../hooks/useLinks';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import type { Account } from 'mastodon/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 (
|
||||
<div className='account-fields' onClickCapture={handleClick}>
|
||||
{fields.take(limit).map((pair, i) => (
|
||||
<dl
|
||||
key={i}
|
||||
className={classNames({ verified: pair.get('verified_at') })}
|
||||
>
|
||||
<dt
|
||||
dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }}
|
||||
className='translate'
|
||||
/>
|
||||
|
||||
<dd className='translate' title={pair.get('value_plain') ?? ''}>
|
||||
{pair.get('verified_at') && (
|
||||
<Icon id='check' icon={CheckIcon} className='verified__mark' />
|
||||
)}
|
||||
<span
|
||||
dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }}
|
||||
/>
|
||||
</dd>
|
||||
</dl>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
93
app/javascript/mastodon/components/follow_button.tsx
Normal file
93
app/javascript/mastodon/components/follow_button.tsx
Normal file
|
@ -0,0 +1,93 @@
|
|||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import {
|
||||
fetchRelationships,
|
||||
followAccount,
|
||||
unfollowAccount,
|
||||
} from 'mastodon/actions/accounts';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
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',
|
||||
},
|
||||
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 = <LoadingIndicator />;
|
||||
} else if (relationship.requested) {
|
||||
label = intl.formatMessage(messages.cancel_follow_request);
|
||||
} else if (relationship.following && relationship.followed_by) {
|
||||
label = intl.formatMessage(messages.mutual);
|
||||
} 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 (
|
||||
<a
|
||||
href='/settings/profile'
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
className='button button-secondary'
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
disabled={relationship?.blocked_by || relationship?.blocking}
|
||||
secondary={following}
|
||||
className={following ? 'button--destructive' : undefined}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
74
app/javascript/mastodon/components/hover_card_account.tsx
Normal file
74
app/javascript/mastodon/components/hover_card_account.tsx
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { useEffect, forwardRef } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { fetchAccount } from 'mastodon/actions/accounts';
|
||||
import { AccountBio } from 'mastodon/components/account_bio';
|
||||
import { AccountFields } from 'mastodon/components/account_fields';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { FollowersCounter } from 'mastodon/components/counters';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { FollowButton } from 'mastodon/components/follow_button';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/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 (
|
||||
<div
|
||||
ref={ref}
|
||||
id='hover-card'
|
||||
role='tooltip'
|
||||
className={classNames('hover-card dropdown-animation', {
|
||||
'hover-card--loading': !account,
|
||||
})}
|
||||
>
|
||||
{account ? (
|
||||
<>
|
||||
<Link to={`/@${account.acct}`} className='hover-card__name'>
|
||||
<Avatar account={account} size={46} />
|
||||
<DisplayName account={account} localDomain={domain} />
|
||||
</Link>
|
||||
|
||||
<div className='hover-card__text-row'>
|
||||
<AccountBio
|
||||
note={account.note_emojified}
|
||||
className='hover-card__bio'
|
||||
/>
|
||||
<AccountFields fields={account.fields} limit={2} />
|
||||
</div>
|
||||
|
||||
<div className='hover-card__number'>
|
||||
<ShortNumber
|
||||
value={account.followers_count}
|
||||
renderer={FollowersCounter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FollowButton accountId={accountId} />
|
||||
</>
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
HoverCardAccount.displayName = 'HoverCardAccount';
|
117
app/javascript/mastodon/components/hover_card_controller.tsx
Normal file
117
app/javascript/mastodon/components/hover_card_controller.tsx
Normal file
|
@ -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 { useTimeout } from 'mastodon/../hooks/useTimeout';
|
||||
import { HoverCardAccount } from 'mastodon/components/hover_card_account';
|
||||
|
||||
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<string | undefined>();
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(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 (
|
||||
<Overlay
|
||||
rootClose
|
||||
onHide={handleClose}
|
||||
show={open}
|
||||
target={anchor}
|
||||
placement='bottom-start'
|
||||
flip
|
||||
offset={offset}
|
||||
popperConfig={popperConfig}
|
||||
>
|
||||
{({ props }) => (
|
||||
<div {...props} className='hover-card-controller'>
|
||||
<HoverCardAccount accountId={accountId} ref={cardRef} />
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
);
|
||||
};
|
|
@ -425,7 +425,7 @@ class Status extends ImmutablePureComponent {
|
|||
prepend = (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend-icon-wrapper'><Icon id='retweet' icon={RepeatIcon} className='status__prepend-icon' /></div>
|
||||
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
|
||||
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} data-hover-card-account={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
|
@ -446,7 +446,7 @@ class Status extends ImmutablePureComponent {
|
|||
prepend = (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend-icon-wrapper'><Icon id='reply' icon={ReplyIcon} className='status__prepend-icon' /></div>
|
||||
<FormattedMessage id='status.replied_to' defaultMessage='Replied to {name}' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
|
||||
<FormattedMessage id='status.replied_to' defaultMessage='Replied to {name}' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} data-hover-card-account={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -562,7 +562,7 @@ class Status extends ImmutablePureComponent {
|
|||
<RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
|
||||
</a>
|
||||
|
||||
<a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
|
||||
<a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} data-hover-card-account={status.getIn(['account', 'id'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
|
||||
<div className='status__avatar'>
|
||||
{statusAvatar}
|
||||
</div>
|
||||
|
|
|
@ -116,8 +116,9 @@ 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('href', `/@${mention.get('acct')}`);
|
||||
link.setAttribute('data-hover-card-account', mention.get('id'));
|
||||
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
|
||||
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
|
||||
link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue