Add hover cards in web UI (#30754)

Co-authored-by: Renaud Chaput <renchap@gmail.com>
main-rebase-security-fix
Eugen Rochko 2024-06-26 21:33:38 +02:00 committed by GitHub
parent 863c470a2b
commit e89317d4c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 631 additions and 42 deletions

View File

@ -0,0 +1,61 @@
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { openURL } from 'mastodon/actions/search';
import { useAppDispatch } from 'mastodon/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

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

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

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

View 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';

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

View File

@ -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>

View File

@ -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(/^#/, '')}`);

View File

@ -9,7 +9,7 @@ export const AuthorLink = ({ accountId }) => {
const account = useAppSelector(state => state.getIn(['accounts', accountId]));
return (
<Link to={`/@${account.get('acct')}`} className='story__details__shared__author-link'>
<Link 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') }} />
</Link>

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 'mastodon/actions/accounts';
import { dismissSuggestion } from 'mastodon/actions/suggestions';
import { Avatar } from 'mastodon/components/avatar';
import { Button } from 'mastodon/components/button';
import { DisplayName } from 'mastodon/components/display_name';
import { FollowButton } from 'mastodon/components/follow_button';
import { IconButton } from 'mastodon/components/icon_button';
import { domain } from 'mastodon/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 'mastodon/actions/accounts';
import { changeSetting } from 'mastodon/actions/settings';
import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions';
import { Avatar } from 'mastodon/components/avatar';
import { Button } from 'mastodon/components/button';
import { DisplayName } from 'mastodon/components/display_name';
import { FollowButton } from 'mastodon/components/follow_button';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { VerifiedBadge } from 'mastodon/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

@ -435,7 +435,7 @@ class Notification extends ImmutablePureComponent {
const targetAccount = report.get('target_account');
const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') };
const targetLink = <bdi><Link className='notification__display-name' title={targetAccount.get('acct')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
const targetLink = <bdi><Link className='notification__display-name' data-hover-card-account={targetAccount.get('id')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
return (
<HotKeys handlers={this.getHandlers()}>
@ -458,7 +458,7 @@ class Notification extends ImmutablePureComponent {
const { notification } = this.props;
const account = notification.get('account');
const displayNameHtml = { __html: account.get('display_name_html') };
const link = <bdi><Link className='notification__display-name' href={`/@${account.get('acct')}`} title={account.get('acct')} to={`/@${account.get('acct')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
const link = <bdi><Link className='notification__display-name' href={`/@${account.get('acct')}`} data-hover-card-account={account.get('id')} to={`/@${account.get('acct')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
switch(notification.get('type')) {
case 'follow':

View File

@ -272,7 +272,7 @@ class DetailedStatus extends ImmutablePureComponent {
<FormattedMessage id='status.direct_indicator' defaultMessage='Private mention' />
</div>
)}
<a href={`/@${status.getIn(['account', 'acct'])}`} onClick={this.handleAccountClick} className='detailed-status__display-name'>
<a href={`/@${status.getIn(['account', 'acct'])}`} 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={46} /></div>
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
</a>

View File

@ -14,6 +14,7 @@ import { HotKeys } from 'react-hotkeys';
import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
import { HoverCardController } from 'mastodon/components/hover_card_controller';
import { PictureInPicture } from 'mastodon/features/picture_in_picture';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { layoutFromWindow } from 'mastodon/is_mobile';
@ -585,6 +586,7 @@ class UI extends PureComponent {
{layout !== 'mobile' && <PictureInPicture />}
<NotificationsContainer />
<HoverCardController />
<LoadingBarContainer className='loading-bar' />
<ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />

View File

@ -35,9 +35,9 @@
"account.follow_back": "Follow back",
"account.followers": "Followers",
"account.followers.empty": "No one follows this user yet.",
"account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
"account.followers_counter": "{count, plural, one {{counter} follower} other {{counter} followers}}",
"account.following": "Following",
"account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
"account.following_counter": "{count, plural, one {{counter} following} other {{counter} following}}",
"account.follows.empty": "This user doesn't follow anyone yet.",
"account.go_to_profile": "Go to profile",
"account.hide_reblogs": "Hide boosts from @{name}",
@ -63,7 +63,7 @@
"account.requested_follow": "{name} has requested to follow you",
"account.share": "Share @{name}'s profile",
"account.show_reblogs": "Show boosts from @{name}",
"account.statuses_counter": "{count, plural, one {{counter} Post} other {{counter} Posts}}",
"account.statuses_counter": "{count, plural, one {{counter} post} other {{counter} posts}}",
"account.unblock": "Unblock @{name}",
"account.unblock_domain": "Unblock domain {domain}",
"account.unblock_short": "Unblock",

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%);

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;
}
}
}
@ -2420,7 +2439,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 {
@ -10325,3 +10344,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;
}
}
}
}
}
}