[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
parent
4179f5fcf3
commit
98185247b8
|
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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';
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -181,7 +181,8 @@ class StatusContent extends PureComponent {
|
||||||
|
|
||||||
if (mention) {
|
if (mention) {
|
||||||
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
|
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') {
|
if (rewriteMentions !== 'no') {
|
||||||
while (link.firstChild) link.removeChild(link.firstChild);
|
while (link.firstChild) link.removeChild(link.firstChild);
|
||||||
link.appendChild(document.createTextNode('@'));
|
link.appendChild(document.createTextNode('@'));
|
||||||
|
|
|
@ -51,6 +51,7 @@ export default class StatusHeader extends PureComponent {
|
||||||
target='_blank'
|
target='_blank'
|
||||||
onClick={this.handleAccountClick}
|
onClick={this.handleAccountClick}
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
|
data-hover-card-account={status.getIn(['account', 'id'])}
|
||||||
>
|
>
|
||||||
<div className='status__avatar'>
|
<div className='status__avatar'>
|
||||||
{statusAvatar}
|
{statusAvatar}
|
||||||
|
|
|
@ -38,6 +38,7 @@ export default class StatusPrepend extends PureComponent {
|
||||||
onClick={this.handleClick}
|
onClick={this.handleClick}
|
||||||
href={account.get('url')}
|
href={account.get('url')}
|
||||||
className='status__display-name'
|
className='status__display-name'
|
||||||
|
data-hover-card-account={account.get('id')}
|
||||||
>
|
>
|
||||||
<bdi>
|
<bdi>
|
||||||
<strong
|
<strong
|
||||||
|
|
|
@ -12,7 +12,7 @@ export const AuthorLink = ({ accountId }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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} />
|
<Avatar account={account} size={16} />
|
||||||
<bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
|
<bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
|
||||||
</Permalink>
|
</Permalink>
|
||||||
|
|
|
@ -8,34 +8,21 @@ import { Link } from 'react-router-dom';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
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 { dismissSuggestion } from 'flavours/glitch/actions/suggestions';
|
||||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
|
||||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
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 { IconButton } from 'flavours/glitch/components/icon_button';
|
||||||
import { domain } from 'flavours/glitch/initial_state';
|
import { domain } from 'flavours/glitch/initial_state';
|
||||||
|
|
||||||
const messages = defineMessages({
|
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" },
|
dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Card = ({ id, source }) => {
|
export const Card = ({ id, source }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const account = useSelector(state => state.getIn(['accounts', id]));
|
const account = useSelector(state => state.getIn(['accounts', id]));
|
||||||
const relationship = useSelector(state => state.getIn(['relationships', id]));
|
|
||||||
const dispatch = useDispatch();
|
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(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
dispatch(dismissSuggestion(id));
|
dispatch(dismissSuggestion(id));
|
||||||
|
@ -74,7 +61,7 @@ export const Card = ({ id, source }) => {
|
||||||
<div className='explore__suggestions__card__body__main__name-button'>
|
<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>
|
<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)} />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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 ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
import InfoIcon from '@/material-icons/400-24px/info.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 { changeSetting } from 'flavours/glitch/actions/settings';
|
||||||
import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions';
|
import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions';
|
||||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
|
||||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||||
|
import { FollowButton } from 'flavours/glitch/components/follow_button';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||||
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
|
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
|
||||||
|
@ -79,18 +78,8 @@ Source.propTypes = {
|
||||||
const Card = ({ id, sources }) => {
|
const Card = ({ id, sources }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const account = useSelector(state => state.getIn(['accounts', id]));
|
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 firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
|
||||||
const dispatch = useDispatch();
|
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(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
dispatch(dismissSuggestion(id));
|
dispatch(dismissSuggestion(id));
|
||||||
|
@ -109,7 +98,7 @@ const Card = ({ id, sources }) => {
|
||||||
{firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={sources.get(0)} />}
|
{firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={sources.get(0)} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} secondary={following} onClick={handleFollow} />
|
<FollowButton accountId={id} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -389,6 +389,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
title={targetAccount.get('acct')}
|
title={targetAccount.get('acct')}
|
||||||
to={`/@${targetAccount.get('acct')}`}
|
to={`/@${targetAccount.get('acct')}`}
|
||||||
dangerouslySetInnerHTML={targetDisplayNameHtml}
|
dangerouslySetInnerHTML={targetDisplayNameHtml}
|
||||||
|
data-hover-card-account={targetAccount.get('id')}
|
||||||
/>
|
/>
|
||||||
</bdi>
|
</bdi>
|
||||||
);
|
);
|
||||||
|
@ -423,6 +424,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
title={account.get('acct')}
|
title={account.get('acct')}
|
||||||
to={`/@${account.get('acct')}`}
|
to={`/@${account.get('acct')}`}
|
||||||
dangerouslySetInnerHTML={displayNameHtml}
|
dangerouslySetInnerHTML={displayNameHtml}
|
||||||
|
data-hover-card-account={account.get('id')}
|
||||||
/>
|
/>
|
||||||
</bdi>
|
</bdi>
|
||||||
);
|
);
|
||||||
|
|
|
@ -285,7 +285,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||||
return (
|
return (
|
||||||
<div style={outerStyle}>
|
<div style={outerStyle}>
|
||||||
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} data-status-by={status.getIn(['account', 'acct'])}>
|
<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>
|
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
|
||||||
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
|
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -15,6 +15,7 @@ import { HotKeys } from 'react-hotkeys';
|
||||||
import { changeLayout } from 'flavours/glitch/actions/app';
|
import { changeLayout } from 'flavours/glitch/actions/app';
|
||||||
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
|
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
|
||||||
import { INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding';
|
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 { Permalink } from 'flavours/glitch/components/permalink';
|
||||||
import { PictureInPicture } from 'flavours/glitch/features/picture_in_picture';
|
import { PictureInPicture } from 'flavours/glitch/features/picture_in_picture';
|
||||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||||
|
@ -648,6 +649,7 @@ class UI extends PureComponent {
|
||||||
|
|
||||||
{layout !== 'mobile' && <PictureInPicture />}
|
{layout !== 'mobile' && <PictureInPicture />}
|
||||||
<NotificationsContainer />
|
<NotificationsContainer />
|
||||||
|
<HoverCardController />
|
||||||
<LoadingBarContainer className='loading-bar' />
|
<LoadingBarContainer className='loading-bar' />
|
||||||
<ModalContainer />
|
<ModalContainer />
|
||||||
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
|
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
|
@ -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;
|
||||||
|
};
|
|
@ -120,8 +120,27 @@
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&.button--destructive {
|
||||||
opacity: 0.5;
|
&: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 {
|
.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 {
|
@keyframes dropdown {
|
||||||
from {
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -59,6 +59,8 @@ $emojis-requiring-inversion: 'chains';
|
||||||
body {
|
body {
|
||||||
--dropdown-border-color: #d9e1e8;
|
--dropdown-border-color: #d9e1e8;
|
||||||
--dropdown-background-color: #fff;
|
--dropdown-background-color: #fff;
|
||||||
|
--modal-border-color: #d9e1e8;
|
||||||
|
--modal-background-color: var(--background-color-tint);
|
||||||
--background-border-color: #d9e1e8;
|
--background-border-color: #d9e1e8;
|
||||||
--background-color: #fff;
|
--background-color: #fff;
|
||||||
--background-color-tint: rgba(255, 255, 255, 80%);
|
--background-color-tint: rgba(255, 255, 255, 80%);
|
||||||
|
|
Loading…
Reference in New Issue