[Glitch] Add hover cards in web UI

Port e89317d4c1

Co-authored-by: Renaud Chaput <renchap@gmail.com>
Signed-off-by: Claire <claire.github-309c@sitedethib.com>
pull/2760/head
Eugen Rochko 2024-06-26 21:33:38 +02:00 committed by Claire
parent 4179f5fcf3
commit 98185247b8
18 changed files with 628 additions and 34 deletions

View File

@ -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 === '<p></p>') {
return null;
}
return (
<div
className={`${className} translate`}
dangerouslySetInnerHTML={{ __html: note }}
onClickCapture={handleClick}
/>
);
};

View File

@ -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 (
<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>
);
};

View File

@ -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 = <LoadingIndicator />;
} 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 (
<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>
);
};

View File

@ -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 (
<div
ref={ref}
id='hover-card'
role='tooltip'
className={classNames('hover-card dropdown-animation', {
'hover-card--loading': !account,
})}
>
{account ? (
<>
<Permalink
to={`/@${account.acct}`}
href={account.get('url')}
className='hover-card__name'
>
<Avatar account={account} size={46} />
<DisplayName account={account} localDomain={domain} />
</Permalink>
<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';

View 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 { 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<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>
);
};

View File

@ -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('@'));

View File

@ -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'])}
>
<div className='status__avatar'>
{statusAvatar}

View File

@ -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')}
>
<bdi>
<strong

View File

@ -12,7 +12,7 @@ export const AuthorLink = ({ accountId }) => {
}
return (
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='story__details__shared__author-link'>
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='story__details__shared__author-link' data-hover-card-account={accountId}>
<Avatar account={account} size={16} />
<bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
</Permalink>

View File

@ -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 }) => {
<div className='explore__suggestions__card__body__main__name-button'>
<Link className='explore__suggestions__card__body__main__name-button__name' to={`/@${account.get('acct')}`}><DisplayName account={account} /></Link>
<IconButton iconComponent={CloseIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
<Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} secondary={following} onClick={handleFollow} />
<FollowButton accountId={account.get('id')} />
</div>
</div>
</div>

View File

@ -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 ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={sources.get(0)} />}
</div>
<Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} secondary={following} onClick={handleFollow} />
<FollowButton accountId={id} />
</div>
);
};

View File

@ -389,6 +389,7 @@ class Notification extends ImmutablePureComponent {
title={targetAccount.get('acct')}
to={`/@${targetAccount.get('acct')}`}
dangerouslySetInnerHTML={targetDisplayNameHtml}
data-hover-card-account={targetAccount.get('id')}
/>
</bdi>
);
@ -423,6 +424,7 @@ class Notification extends ImmutablePureComponent {
title={account.get('acct')}
to={`/@${account.get('acct')}`}
dangerouslySetInnerHTML={displayNameHtml}
data-hover-card-account={account.get('id')}
/>
</bdi>
);

View File

@ -285,7 +285,7 @@ class DetailedStatus extends ImmutablePureComponent {
return (
<div style={outerStyle}>
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} data-status-by={status.getIn(['account', 'acct'])}>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
<a href={status.getIn(['account', 'url'])} data-hover-card-account={status.getIn(['account', 'id'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
</a>

View File

@ -15,6 +15,7 @@ import { HotKeys } from 'react-hotkeys';
import { changeLayout } from 'flavours/glitch/actions/app';
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
import { INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding';
import { HoverCardController } from 'flavours/glitch/components/hover_card_controller';
import { Permalink } from 'flavours/glitch/components/permalink';
import { PictureInPicture } from 'flavours/glitch/features/picture_in_picture';
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
@ -648,6 +649,7 @@ class UI extends PureComponent {
{layout !== 'mobile' && <PictureInPicture />}
<NotificationsContainer />
<HoverCardController />
<LoadingBarContainer className='loading-bar' />
<ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />

View File

@ -0,0 +1,61 @@
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { openURL } from 'flavours/glitch/actions/search';
import { useAppDispatch } from 'flavours/glitch/store';
const isMentionClick = (element: HTMLAnchorElement) =>
element.classList.contains('mention');
const isHashtagClick = (element: HTMLAnchorElement) =>
element.textContent?.[0] === '#' ||
element.previousSibling?.textContent?.endsWith('#');
export const useLinks = () => {
const history = useHistory();
const dispatch = useAppDispatch();
const handleHashtagClick = useCallback(
(element: HTMLAnchorElement) => {
const { textContent } = element;
if (!textContent) return;
history.push(`/tags/${textContent.replace(/^#/, '')}`);
},
[history],
);
const handleMentionClick = useCallback(
(element: HTMLAnchorElement) => {
dispatch(
openURL(element.href, history, () => {
window.location.href = element.href;
}),
);
},
[dispatch, history],
);
const handleClick = useCallback(
(e: React.MouseEvent) => {
const target = (e.target as HTMLElement).closest('a');
if (!target || e.button !== 0 || e.ctrlKey || e.metaKey) {
return;
}
if (isMentionClick(target)) {
e.preventDefault();
handleMentionClick(target);
} else if (isHashtagClick(target)) {
e.preventDefault();
handleHashtagClick(target);
}
},
[handleMentionClick, handleHashtagClick],
);
return handleClick;
};

View File

@ -0,0 +1,29 @@
import { useRef, useCallback, useEffect } from 'react';
export const useTimeout = () => {
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const set = useCallback((callback: () => void, delay: number) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(callback, delay);
}, []);
const cancel = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = undefined;
}
}, []);
useEffect(
() => () => {
cancel();
},
[cancel],
);
return [set, cancel] as const;
};

View File

@ -120,8 +120,27 @@
text-decoration: none;
}
&:disabled {
opacity: 0.5;
&.button--destructive {
&:active,
&:focus,
&:hover {
border-color: $ui-button-destructive-focus-background-color;
color: $ui-button-destructive-focus-background-color;
}
}
&:disabled,
&.disabled {
opacity: 0.7;
border-color: $ui-primary-color;
color: $ui-primary-color;
&:active,
&:focus,
&:hover {
border-color: $ui-primary-color;
color: $ui-primary-color;
}
}
}
@ -2629,7 +2648,7 @@ a.account__display-name {
}
.dropdown-animation {
animation: dropdown 150ms cubic-bezier(0.1, 0.7, 0.1, 1);
animation: dropdown 250ms cubic-bezier(0.1, 0.7, 0.1, 1);
@keyframes dropdown {
from {
@ -10908,3 +10927,156 @@ noscript {
}
}
}
.hover-card-controller[data-popper-reference-hidden='true'] {
opacity: 0;
pointer-events: none;
}
.hover-card {
box-shadow: var(--dropdown-shadow);
background: var(--modal-background-color);
backdrop-filter: var(--background-filter);
border: 1px solid var(--modal-border-color);
border-radius: 8px;
padding: 16px;
width: 270px;
display: flex;
flex-direction: column;
gap: 12px;
&--loading {
position: relative;
min-height: 100px;
}
&__name {
display: flex;
gap: 12px;
text-decoration: none;
color: inherit;
}
&__number {
font-size: 15px;
line-height: 22px;
color: $secondary-text-color;
strong {
font-weight: 700;
}
}
&__text-row {
display: flex;
flex-direction: column;
gap: 8px;
}
&__bio {
color: $secondary-text-color;
font-size: 14px;
line-height: 20px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
max-height: 2 * 20px;
overflow: hidden;
p {
margin-bottom: 0;
}
a {
color: inherit;
text-decoration: underline;
&:hover,
&:focus,
&:active {
text-decoration: none;
}
}
}
.display-name {
font-size: 15px;
line-height: 22px;
bdi {
font-weight: 500;
color: $primary-text-color;
}
&__account {
display: block;
color: $dark-text-color;
}
}
.account-fields {
color: $secondary-text-color;
font-size: 14px;
line-height: 20px;
a {
color: inherit;
text-decoration: none;
&:focus,
&:hover,
&:active {
text-decoration: underline;
}
}
dl {
display: flex;
align-items: center;
gap: 4px;
dt {
flex: 0 0 auto;
color: $dark-text-color;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
dd {
flex: 1 1 auto;
font-weight: 500;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&.verified {
dd {
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
white-space: nowrap;
color: $valid-value-color;
& > span {
overflow: hidden;
text-overflow: ellipsis;
}
a {
font-weight: 500;
}
.icon {
width: 16px;
height: 16px;
}
}
}
}
}
}

View File

@ -59,6 +59,8 @@ $emojis-requiring-inversion: 'chains';
body {
--dropdown-border-color: #d9e1e8;
--dropdown-background-color: #fff;
--modal-border-color: #d9e1e8;
--modal-background-color: var(--background-color-tint);
--background-border-color: #d9e1e8;
--background-color: #fff;
--background-color-tint: rgba(255, 255, 255, 80%);