Add hover cards in web UI (#30754)
Co-authored-by: Renaud Chaput <renchap@gmail.com>
This commit is contained in:
		
							parent
							
								
									863c470a2b
								
							
						
					
					
						commit
						e89317d4c1
					
				
							
								
								
									
										61
									
								
								app/javascript/hooks/useLinks.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								app/javascript/hooks/useLinks.ts
									
									
									
									
									
										Normal 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;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
							
								
								
									
										29
									
								
								app/javascript/hooks/useTimeout.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								app/javascript/hooks/useTimeout.ts
									
									
									
									
									
										Normal 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;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
							
								
								
									
										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 = (
 | 
					      prepend = (
 | 
				
			||||||
        <div className='status__prepend'>
 | 
					        <div className='status__prepend'>
 | 
				
			||||||
          <div className='status__prepend-icon-wrapper'><Icon id='retweet' icon={RepeatIcon} className='status__prepend-icon' /></div>
 | 
					          <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>
 | 
					        </div>
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -446,7 +446,7 @@ class Status extends ImmutablePureComponent {
 | 
				
			|||||||
      prepend = (
 | 
					      prepend = (
 | 
				
			||||||
        <div className='status__prepend'>
 | 
					        <div className='status__prepend'>
 | 
				
			||||||
          <div className='status__prepend-icon-wrapper'><Icon id='reply' icon={ReplyIcon} className='status__prepend-icon' /></div>
 | 
					          <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>
 | 
					        </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>}
 | 
					                <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>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              <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'>
 | 
					                <div className='status__avatar'>
 | 
				
			||||||
                  {statusAvatar}
 | 
					                  {statusAvatar}
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
 | 
				
			|||||||
@ -116,8 +116,9 @@ 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('href', `/@${mention.get('acct')}`);
 | 
					        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] === '#')) {
 | 
					      } 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.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
 | 
				
			||||||
        link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
 | 
					        link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
 | 
				
			||||||
 | 
				
			|||||||
@ -9,7 +9,7 @@ export const AuthorLink = ({ accountId }) => {
 | 
				
			|||||||
  const account = useAppSelector(state => state.getIn(['accounts', accountId]));
 | 
					  const account = useAppSelector(state => state.getIn(['accounts', accountId]));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  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} />
 | 
					      <Avatar account={account} size={16} />
 | 
				
			||||||
      <bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
 | 
					      <bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
 | 
				
			||||||
    </Link>
 | 
					    </Link>
 | 
				
			||||||
 | 
				
			|||||||
@ -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 'mastodon/actions/accounts';
 | 
					 | 
				
			||||||
import { dismissSuggestion } from 'mastodon/actions/suggestions';
 | 
					import { dismissSuggestion } from 'mastodon/actions/suggestions';
 | 
				
			||||||
import { Avatar } from 'mastodon/components/avatar';
 | 
					import { Avatar } from 'mastodon/components/avatar';
 | 
				
			||||||
import { Button } from 'mastodon/components/button';
 | 
					 | 
				
			||||||
import { DisplayName } from 'mastodon/components/display_name';
 | 
					import { DisplayName } from 'mastodon/components/display_name';
 | 
				
			||||||
 | 
					import { FollowButton } from 'mastodon/components/follow_button';
 | 
				
			||||||
import { IconButton } from 'mastodon/components/icon_button';
 | 
					import { IconButton } from 'mastodon/components/icon_button';
 | 
				
			||||||
import { domain } from 'mastodon/initial_state';
 | 
					import { domain } from 'mastodon/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 'mastodon/actions/accounts';
 | 
					 | 
				
			||||||
import { changeSetting } from 'mastodon/actions/settings';
 | 
					import { changeSetting } from 'mastodon/actions/settings';
 | 
				
			||||||
import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions';
 | 
					import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions';
 | 
				
			||||||
import { Avatar } from 'mastodon/components/avatar';
 | 
					import { Avatar } from 'mastodon/components/avatar';
 | 
				
			||||||
import { Button } from 'mastodon/components/button';
 | 
					 | 
				
			||||||
import { DisplayName } from 'mastodon/components/display_name';
 | 
					import { DisplayName } from 'mastodon/components/display_name';
 | 
				
			||||||
 | 
					import { FollowButton } from 'mastodon/components/follow_button';
 | 
				
			||||||
import { Icon } from 'mastodon/components/icon';
 | 
					import { Icon } from 'mastodon/components/icon';
 | 
				
			||||||
import { IconButton } from 'mastodon/components/icon_button';
 | 
					import { IconButton } from 'mastodon/components/icon_button';
 | 
				
			||||||
import { VerifiedBadge } from 'mastodon/components/verified_badge';
 | 
					import { VerifiedBadge } from 'mastodon/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>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -435,7 +435,7 @@ class Notification extends ImmutablePureComponent {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    const targetAccount = report.get('target_account');
 | 
					    const targetAccount = report.get('target_account');
 | 
				
			||||||
    const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') };
 | 
					    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 (
 | 
					    return (
 | 
				
			||||||
      <HotKeys handlers={this.getHandlers()}>
 | 
					      <HotKeys handlers={this.getHandlers()}>
 | 
				
			||||||
@ -458,7 +458,7 @@ class Notification extends ImmutablePureComponent {
 | 
				
			|||||||
    const { notification } = this.props;
 | 
					    const { notification } = this.props;
 | 
				
			||||||
    const account          = notification.get('account');
 | 
					    const account          = notification.get('account');
 | 
				
			||||||
    const displayNameHtml  = { __html: account.get('display_name_html') };
 | 
					    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')) {
 | 
					    switch(notification.get('type')) {
 | 
				
			||||||
    case 'follow':
 | 
					    case 'follow':
 | 
				
			||||||
 | 
				
			|||||||
@ -272,7 +272,7 @@ class DetailedStatus extends ImmutablePureComponent {
 | 
				
			|||||||
              <FormattedMessage id='status.direct_indicator' defaultMessage='Private mention' />
 | 
					              <FormattedMessage id='status.direct_indicator' defaultMessage='Private mention' />
 | 
				
			||||||
            </div>
 | 
					            </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>
 | 
					            <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={46} /></div>
 | 
				
			||||||
            <DisplayName account={status.get('account')} localDomain={this.props.domain} />
 | 
					            <DisplayName account={status.get('account')} localDomain={this.props.domain} />
 | 
				
			||||||
          </a>
 | 
					          </a>
 | 
				
			||||||
 | 
				
			|||||||
@ -14,6 +14,7 @@ import { HotKeys } from 'react-hotkeys';
 | 
				
			|||||||
import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
 | 
					import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
 | 
				
			||||||
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
 | 
					import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
 | 
				
			||||||
import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
 | 
					import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
 | 
				
			||||||
 | 
					import { HoverCardController } from 'mastodon/components/hover_card_controller';
 | 
				
			||||||
import { PictureInPicture } from 'mastodon/features/picture_in_picture';
 | 
					import { PictureInPicture } from 'mastodon/features/picture_in_picture';
 | 
				
			||||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
 | 
					import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
 | 
				
			||||||
import { layoutFromWindow } from 'mastodon/is_mobile';
 | 
					import { layoutFromWindow } from 'mastodon/is_mobile';
 | 
				
			||||||
@ -585,6 +586,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} />
 | 
				
			||||||
 | 
				
			|||||||
@ -35,9 +35,9 @@
 | 
				
			|||||||
  "account.follow_back": "Follow back",
 | 
					  "account.follow_back": "Follow back",
 | 
				
			||||||
  "account.followers": "Followers",
 | 
					  "account.followers": "Followers",
 | 
				
			||||||
  "account.followers.empty": "No one follows this user yet.",
 | 
					  "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": "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.follows.empty": "This user doesn't follow anyone yet.",
 | 
				
			||||||
  "account.go_to_profile": "Go to profile",
 | 
					  "account.go_to_profile": "Go to profile",
 | 
				
			||||||
  "account.hide_reblogs": "Hide boosts from @{name}",
 | 
					  "account.hide_reblogs": "Hide boosts from @{name}",
 | 
				
			||||||
@ -63,7 +63,7 @@
 | 
				
			|||||||
  "account.requested_follow": "{name} has requested to follow you",
 | 
					  "account.requested_follow": "{name} has requested to follow you",
 | 
				
			||||||
  "account.share": "Share @{name}'s profile",
 | 
					  "account.share": "Share @{name}'s profile",
 | 
				
			||||||
  "account.show_reblogs": "Show boosts from @{name}",
 | 
					  "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": "Unblock @{name}",
 | 
				
			||||||
  "account.unblock_domain": "Unblock domain {domain}",
 | 
					  "account.unblock_domain": "Unblock domain {domain}",
 | 
				
			||||||
  "account.unblock_short": "Unblock",
 | 
					  "account.unblock_short": "Unblock",
 | 
				
			||||||
 | 
				
			|||||||
@ -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%);
 | 
				
			||||||
 | 
				
			|||||||
@ -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;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -2420,7 +2439,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 {
 | 
				
			||||||
@ -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;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user