From e89317d4c1da991b728b6d4a21671ed33f057cc4 Mon Sep 17 00:00:00 2001
From: Eugen Rochko <eugen@zeonfederated.com>
Date: Wed, 26 Jun 2024 21:33:38 +0200
Subject: [PATCH] Add hover cards in web UI (#30754)

Co-authored-by: Renaud Chaput <renchap@gmail.com>
---
 app/javascript/hooks/useLinks.ts              |  61 ++++++
 app/javascript/hooks/useTimeout.ts            |  29 +++
 .../mastodon/components/account_bio.tsx       |  20 ++
 .../mastodon/components/account_fields.tsx    |  42 +++++
 .../mastodon/components/follow_button.tsx     |  93 +++++++++
 .../components/hover_card_account.tsx         |  74 ++++++++
 .../components/hover_card_controller.tsx      | 117 ++++++++++++
 app/javascript/mastodon/components/status.jsx |   6 +-
 .../mastodon/components/status_content.jsx    |   3 +-
 .../explore/components/author_link.jsx        |   2 +-
 .../features/explore/components/card.jsx      |  17 +-
 .../components/inline_follow_suggestions.jsx  |  15 +-
 .../notifications/components/notification.jsx |   4 +-
 .../status/components/detailed_status.jsx     |   2 +-
 app/javascript/mastodon/features/ui/index.jsx |   2 +
 app/javascript/mastodon/locales/en.json       |   6 +-
 .../styles/mastodon-light/variables.scss      |   2 +
 .../styles/mastodon/components.scss           | 178 +++++++++++++++++-
 18 files changed, 631 insertions(+), 42 deletions(-)
 create mode 100644 app/javascript/hooks/useLinks.ts
 create mode 100644 app/javascript/hooks/useTimeout.ts
 create mode 100644 app/javascript/mastodon/components/account_bio.tsx
 create mode 100644 app/javascript/mastodon/components/account_fields.tsx
 create mode 100644 app/javascript/mastodon/components/follow_button.tsx
 create mode 100644 app/javascript/mastodon/components/hover_card_account.tsx
 create mode 100644 app/javascript/mastodon/components/hover_card_controller.tsx

diff --git a/app/javascript/hooks/useLinks.ts b/app/javascript/hooks/useLinks.ts
new file mode 100644
index 000000000..f08b9500d
--- /dev/null
+++ b/app/javascript/hooks/useLinks.ts
@@ -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;
+};
diff --git a/app/javascript/hooks/useTimeout.ts b/app/javascript/hooks/useTimeout.ts
new file mode 100644
index 000000000..f1814ae8e
--- /dev/null
+++ b/app/javascript/hooks/useTimeout.ts
@@ -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;
+};
diff --git a/app/javascript/mastodon/components/account_bio.tsx b/app/javascript/mastodon/components/account_bio.tsx
new file mode 100644
index 000000000..9d523c740
--- /dev/null
+++ b/app/javascript/mastodon/components/account_bio.tsx
@@ -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}
+    />
+  );
+};
diff --git a/app/javascript/mastodon/components/account_fields.tsx b/app/javascript/mastodon/components/account_fields.tsx
new file mode 100644
index 000000000..e297f99e3
--- /dev/null
+++ b/app/javascript/mastodon/components/account_fields.tsx
@@ -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>
+  );
+};
diff --git a/app/javascript/mastodon/components/follow_button.tsx b/app/javascript/mastodon/components/follow_button.tsx
new file mode 100644
index 000000000..4b4d27831
--- /dev/null
+++ b/app/javascript/mastodon/components/follow_button.tsx
@@ -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>
+  );
+};
diff --git a/app/javascript/mastodon/components/hover_card_account.tsx b/app/javascript/mastodon/components/hover_card_account.tsx
new file mode 100644
index 000000000..59f957783
--- /dev/null
+++ b/app/javascript/mastodon/components/hover_card_account.tsx
@@ -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';
diff --git a/app/javascript/mastodon/components/hover_card_controller.tsx b/app/javascript/mastodon/components/hover_card_controller.tsx
new file mode 100644
index 000000000..0130390ef
--- /dev/null
+++ b/app/javascript/mastodon/components/hover_card_controller.tsx
@@ -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>
+  );
+};
diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx
index 7b97e4576..dce48d703 100644
--- a/app/javascript/mastodon/components/status.jsx
+++ b/app/javascript/mastodon/components/status.jsx
@@ -425,7 +425,7 @@ class Status extends ImmutablePureComponent {
       prepend = (
         <div className='status__prepend'>
           <div className='status__prepend-icon-wrapper'><Icon id='retweet' icon={RepeatIcon} className='status__prepend-icon' /></div>
-          <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
+          <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} data-hover-card-account={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
         </div>
       );
 
@@ -446,7 +446,7 @@ class Status extends ImmutablePureComponent {
       prepend = (
         <div className='status__prepend'>
           <div className='status__prepend-icon-wrapper'><Icon id='reply' icon={ReplyIcon} className='status__prepend-icon' /></div>
-          <FormattedMessage id='status.replied_to' defaultMessage='Replied to {name}' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
+          <FormattedMessage id='status.replied_to' defaultMessage='Replied to {name}' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} data-hover-card-account={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
         </div>
       );
     }
@@ -562,7 +562,7 @@ class Status extends ImmutablePureComponent {
                 <RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
               </a>
 
-              <a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
+              <a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} data-hover-card-account={status.getIn(['account', 'id'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
                 <div className='status__avatar'>
                   {statusAvatar}
                 </div>
diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx
index 24483cf51..82135b85c 100644
--- a/app/javascript/mastodon/components/status_content.jsx
+++ b/app/javascript/mastodon/components/status_content.jsx
@@ -116,8 +116,9 @@ class StatusContent extends PureComponent {
 
       if (mention) {
         link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
-        link.setAttribute('title', `@${mention.get('acct')}`);
+        link.removeAttribute('title');
         link.setAttribute('href', `/@${mention.get('acct')}`);
+        link.setAttribute('data-hover-card-account', mention.get('id'));
       } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
         link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
         link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
diff --git a/app/javascript/mastodon/features/explore/components/author_link.jsx b/app/javascript/mastodon/features/explore/components/author_link.jsx
index b9dec3367..8dd9b0dab 100644
--- a/app/javascript/mastodon/features/explore/components/author_link.jsx
+++ b/app/javascript/mastodon/features/explore/components/author_link.jsx
@@ -9,7 +9,7 @@ export const AuthorLink = ({ accountId }) => {
   const account = useAppSelector(state => state.getIn(['accounts', accountId]));
 
   return (
-    <Link to={`/@${account.get('acct')}`} className='story__details__shared__author-link'>
+    <Link to={`/@${account.get('acct')}`} className='story__details__shared__author-link' data-hover-card-account={accountId}>
       <Avatar account={account} size={16} />
       <bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
     </Link>
diff --git a/app/javascript/mastodon/features/explore/components/card.jsx b/app/javascript/mastodon/features/explore/components/card.jsx
index 316203060..190864851 100644
--- a/app/javascript/mastodon/features/explore/components/card.jsx
+++ b/app/javascript/mastodon/features/explore/components/card.jsx
@@ -8,34 +8,21 @@ import { Link } from 'react-router-dom';
 import { useDispatch, useSelector } from 'react-redux';
 
 import CloseIcon from '@/material-icons/400-24px/close.svg?react';
-import { followAccount, unfollowAccount } from 'mastodon/actions/accounts';
 import { dismissSuggestion } from 'mastodon/actions/suggestions';
 import { Avatar } from 'mastodon/components/avatar';
-import { Button } from 'mastodon/components/button';
 import { DisplayName } from 'mastodon/components/display_name';
+import { FollowButton } from 'mastodon/components/follow_button';
 import { IconButton } from 'mastodon/components/icon_button';
 import { domain } from 'mastodon/initial_state';
 
 const messages = defineMessages({
-  follow: { id: 'account.follow', defaultMessage: 'Follow' },
-  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
   dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" },
 });
 
 export const Card = ({ id, source }) => {
   const intl = useIntl();
   const account = useSelector(state => state.getIn(['accounts', id]));
-  const relationship = useSelector(state => state.getIn(['relationships', id]));
   const dispatch = useDispatch();
-  const following = relationship?.get('following') ?? relationship?.get('requested');
-
-  const handleFollow = useCallback(() => {
-    if (following) {
-      dispatch(unfollowAccount(id));
-    } else {
-      dispatch(followAccount(id));
-    }
-  }, [id, following, dispatch]);
 
   const handleDismiss = useCallback(() => {
     dispatch(dismissSuggestion(id));
@@ -74,7 +61,7 @@ export const Card = ({ id, source }) => {
           <div className='explore__suggestions__card__body__main__name-button'>
             <Link className='explore__suggestions__card__body__main__name-button__name' to={`/@${account.get('acct')}`}><DisplayName account={account} /></Link>
             <IconButton iconComponent={CloseIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
-            <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} secondary={following} onClick={handleFollow} />
+            <FollowButton accountId={account.get('id')} />
           </div>
         </div>
       </div>
diff --git a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx
index c39b43bad..1b8040e55 100644
--- a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx
+++ b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx
@@ -12,12 +12,11 @@ import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
 import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
 import CloseIcon from '@/material-icons/400-24px/close.svg?react';
 import InfoIcon from '@/material-icons/400-24px/info.svg?react';
-import { followAccount, unfollowAccount } from 'mastodon/actions/accounts';
 import { changeSetting } from 'mastodon/actions/settings';
 import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions';
 import { Avatar } from 'mastodon/components/avatar';
-import { Button } from 'mastodon/components/button';
 import { DisplayName } from 'mastodon/components/display_name';
+import { FollowButton } from 'mastodon/components/follow_button';
 import { Icon } from 'mastodon/components/icon';
 import { IconButton } from 'mastodon/components/icon_button';
 import { VerifiedBadge } from 'mastodon/components/verified_badge';
@@ -79,18 +78,8 @@ Source.propTypes = {
 const Card = ({ id, sources }) => {
   const intl = useIntl();
   const account = useSelector(state => state.getIn(['accounts', id]));
-  const relationship = useSelector(state => state.getIn(['relationships', id]));
   const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
   const dispatch = useDispatch();
-  const following = relationship?.get('following') ?? relationship?.get('requested');
-
-  const handleFollow = useCallback(() => {
-    if (following) {
-      dispatch(unfollowAccount(id));
-    } else {
-      dispatch(followAccount(id));
-    }
-  }, [id, following, dispatch]);
 
   const handleDismiss = useCallback(() => {
     dispatch(dismissSuggestion(id));
@@ -109,7 +98,7 @@ const Card = ({ id, sources }) => {
         {firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={sources.get(0)} />}
       </div>
 
-      <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} secondary={following} onClick={handleFollow} />
+      <FollowButton accountId={id} />
     </div>
   );
 };
diff --git a/app/javascript/mastodon/features/notifications/components/notification.jsx b/app/javascript/mastodon/features/notifications/components/notification.jsx
index 69084c211..272893042 100644
--- a/app/javascript/mastodon/features/notifications/components/notification.jsx
+++ b/app/javascript/mastodon/features/notifications/components/notification.jsx
@@ -435,7 +435,7 @@ class Notification extends ImmutablePureComponent {
 
     const targetAccount = report.get('target_account');
     const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') };
-    const targetLink = <bdi><Link className='notification__display-name' title={targetAccount.get('acct')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
+    const targetLink = <bdi><Link className='notification__display-name' data-hover-card-account={targetAccount.get('id')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
 
     return (
       <HotKeys handlers={this.getHandlers()}>
@@ -458,7 +458,7 @@ class Notification extends ImmutablePureComponent {
     const { notification } = this.props;
     const account          = notification.get('account');
     const displayNameHtml  = { __html: account.get('display_name_html') };
-    const link             = <bdi><Link className='notification__display-name' href={`/@${account.get('acct')}`} title={account.get('acct')} to={`/@${account.get('acct')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
+    const link             = <bdi><Link className='notification__display-name' href={`/@${account.get('acct')}`} data-hover-card-account={account.get('id')} to={`/@${account.get('acct')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
 
     switch(notification.get('type')) {
     case 'follow':
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.jsx b/app/javascript/mastodon/features/status/components/detailed_status.jsx
index 8843619bc..bc81fd2df 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.jsx
+++ b/app/javascript/mastodon/features/status/components/detailed_status.jsx
@@ -272,7 +272,7 @@ class DetailedStatus extends ImmutablePureComponent {
               <FormattedMessage id='status.direct_indicator' defaultMessage='Private mention' />
             </div>
           )}
-          <a href={`/@${status.getIn(['account', 'acct'])}`} onClick={this.handleAccountClick} className='detailed-status__display-name'>
+          <a href={`/@${status.getIn(['account', 'acct'])}`} data-hover-card-account={status.getIn(['account', 'id'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
             <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={46} /></div>
             <DisplayName account={status.get('account')} localDomain={this.props.domain} />
           </a>
diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx
index 7742f6486..b58e191ed 100644
--- a/app/javascript/mastodon/features/ui/index.jsx
+++ b/app/javascript/mastodon/features/ui/index.jsx
@@ -14,6 +14,7 @@ import { HotKeys } from 'react-hotkeys';
 import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
 import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
 import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
+import { HoverCardController } from 'mastodon/components/hover_card_controller';
 import { PictureInPicture } from 'mastodon/features/picture_in_picture';
 import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
 import { layoutFromWindow } from 'mastodon/is_mobile';
@@ -585,6 +586,7 @@ class UI extends PureComponent {
 
           {layout !== 'mobile' && <PictureInPicture />}
           <NotificationsContainer />
+          <HoverCardController />
           <LoadingBarContainer className='loading-bar' />
           <ModalContainer />
           <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index f0c27ad70..13296e1d2 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -35,9 +35,9 @@
   "account.follow_back": "Follow back",
   "account.followers": "Followers",
   "account.followers.empty": "No one follows this user yet.",
-  "account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
+  "account.followers_counter": "{count, plural, one {{counter} follower} other {{counter} followers}}",
   "account.following": "Following",
-  "account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
+  "account.following_counter": "{count, plural, one {{counter} following} other {{counter} following}}",
   "account.follows.empty": "This user doesn't follow anyone yet.",
   "account.go_to_profile": "Go to profile",
   "account.hide_reblogs": "Hide boosts from @{name}",
@@ -63,7 +63,7 @@
   "account.requested_follow": "{name} has requested to follow you",
   "account.share": "Share @{name}'s profile",
   "account.show_reblogs": "Show boosts from @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Post} other {{counter} Posts}}",
+  "account.statuses_counter": "{count, plural, one {{counter} post} other {{counter} posts}}",
   "account.unblock": "Unblock @{name}",
   "account.unblock_domain": "Unblock domain {domain}",
   "account.unblock_short": "Unblock",
diff --git a/app/javascript/styles/mastodon-light/variables.scss b/app/javascript/styles/mastodon-light/variables.scss
index 3cdbd9bf6..9f571b3f2 100644
--- a/app/javascript/styles/mastodon-light/variables.scss
+++ b/app/javascript/styles/mastodon-light/variables.scss
@@ -59,6 +59,8 @@ $emojis-requiring-inversion: 'chains';
 body {
   --dropdown-border-color: #d9e1e8;
   --dropdown-background-color: #fff;
+  --modal-border-color: #d9e1e8;
+  --modal-background-color: var(--background-color-tint);
   --background-border-color: #d9e1e8;
   --background-color: #fff;
   --background-color-tint: rgba(255, 255, 255, 80%);
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 73d0e6220..cbf9314ff 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -120,8 +120,27 @@
       text-decoration: none;
     }
 
-    &:disabled {
-      opacity: 0.5;
+    &.button--destructive {
+      &:active,
+      &:focus,
+      &:hover {
+        border-color: $ui-button-destructive-focus-background-color;
+        color: $ui-button-destructive-focus-background-color;
+      }
+    }
+
+    &:disabled,
+    &.disabled {
+      opacity: 0.7;
+      border-color: $ui-primary-color;
+      color: $ui-primary-color;
+
+      &:active,
+      &:focus,
+      &:hover {
+        border-color: $ui-primary-color;
+        color: $ui-primary-color;
+      }
     }
   }
 
@@ -2420,7 +2439,7 @@ a.account__display-name {
 }
 
 .dropdown-animation {
-  animation: dropdown 150ms cubic-bezier(0.1, 0.7, 0.1, 1);
+  animation: dropdown 250ms cubic-bezier(0.1, 0.7, 0.1, 1);
 
   @keyframes dropdown {
     from {
@@ -10325,3 +10344,156 @@ noscript {
     }
   }
 }
+
+.hover-card-controller[data-popper-reference-hidden='true'] {
+  opacity: 0;
+  pointer-events: none;
+}
+
+.hover-card {
+  box-shadow: var(--dropdown-shadow);
+  background: var(--modal-background-color);
+  backdrop-filter: var(--background-filter);
+  border: 1px solid var(--modal-border-color);
+  border-radius: 8px;
+  padding: 16px;
+  width: 270px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+
+  &--loading {
+    position: relative;
+    min-height: 100px;
+  }
+
+  &__name {
+    display: flex;
+    gap: 12px;
+    text-decoration: none;
+    color: inherit;
+  }
+
+  &__number {
+    font-size: 15px;
+    line-height: 22px;
+    color: $secondary-text-color;
+
+    strong {
+      font-weight: 700;
+    }
+  }
+
+  &__text-row {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+  }
+
+  &__bio {
+    color: $secondary-text-color;
+    font-size: 14px;
+    line-height: 20px;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    max-height: 2 * 20px;
+    overflow: hidden;
+
+    p {
+      margin-bottom: 0;
+    }
+
+    a {
+      color: inherit;
+      text-decoration: underline;
+
+      &:hover,
+      &:focus,
+      &:active {
+        text-decoration: none;
+      }
+    }
+  }
+
+  .display-name {
+    font-size: 15px;
+    line-height: 22px;
+
+    bdi {
+      font-weight: 500;
+      color: $primary-text-color;
+    }
+
+    &__account {
+      display: block;
+      color: $dark-text-color;
+    }
+  }
+
+  .account-fields {
+    color: $secondary-text-color;
+    font-size: 14px;
+    line-height: 20px;
+
+    a {
+      color: inherit;
+      text-decoration: none;
+
+      &:focus,
+      &:hover,
+      &:active {
+        text-decoration: underline;
+      }
+    }
+
+    dl {
+      display: flex;
+      align-items: center;
+      gap: 4px;
+
+      dt {
+        flex: 0 0 auto;
+        color: $dark-text-color;
+        min-width: 0;
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+      }
+
+      dd {
+        flex: 1 1 auto;
+        font-weight: 500;
+        min-width: 0;
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+      }
+
+      &.verified {
+        dd {
+          display: flex;
+          align-items: center;
+          gap: 4px;
+          overflow: hidden;
+          white-space: nowrap;
+          color: $valid-value-color;
+
+          & > span {
+            overflow: hidden;
+            text-overflow: ellipsis;
+          }
+
+          a {
+            font-weight: 500;
+          }
+
+          .icon {
+            width: 16px;
+            height: 16px;
+          }
+        }
+      }
+    }
+  }
+}