From b728c0e8ce9ac3a74f116bedff85e36dd7cc6a1e Mon Sep 17 00:00:00 2001
From: Eugen Rochko <eugen@zeonfederated.com>
Date: Mon, 1 Jul 2024 17:52:01 +0200
Subject: [PATCH] Change hover cards to not appear until the mouse stops in web
 UI (#30850)

---
 app/javascript/hooks/useTimeout.ts            |  17 +-
 .../mastodon/components/follow_button.tsx     |   4 +-
 .../components/hover_card_account.tsx         |   2 +-
 .../components/hover_card_controller.tsx      | 159 ++++++++++++------
 .../components/conversation.jsx               |   2 +-
 .../styles/mastodon/components.scss           |   2 +
 6 files changed, 131 insertions(+), 55 deletions(-)

diff --git a/app/javascript/hooks/useTimeout.ts b/app/javascript/hooks/useTimeout.ts
index f1814ae8e3..bb1e8848dd 100644
--- a/app/javascript/hooks/useTimeout.ts
+++ b/app/javascript/hooks/useTimeout.ts
@@ -2,19 +2,34 @@ import { useRef, useCallback, useEffect } from 'react';
 
 export const useTimeout = () => {
   const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
+  const callbackRef = useRef<() => void>();
 
   const set = useCallback((callback: () => void, delay: number) => {
     if (timeoutRef.current) {
       clearTimeout(timeoutRef.current);
     }
 
+    callbackRef.current = callback;
     timeoutRef.current = setTimeout(callback, delay);
   }, []);
 
+  const delay = useCallback((delay: number) => {
+    if (timeoutRef.current) {
+      clearTimeout(timeoutRef.current);
+    }
+
+    if (!callbackRef.current) {
+      return;
+    }
+
+    timeoutRef.current = setTimeout(callbackRef.current, delay);
+  }, []);
+
   const cancel = useCallback(() => {
     if (timeoutRef.current) {
       clearTimeout(timeoutRef.current);
       timeoutRef.current = undefined;
+      callbackRef.current = undefined;
     }
   }, []);
 
@@ -25,5 +40,5 @@ export const useTimeout = () => {
     [cancel],
   );
 
-  return [set, cancel] as const;
+  return [set, cancel, delay] as const;
 };
diff --git a/app/javascript/mastodon/components/follow_button.tsx b/app/javascript/mastodon/components/follow_button.tsx
index db59942882..62771c2547 100644
--- a/app/javascript/mastodon/components/follow_button.tsx
+++ b/app/javascript/mastodon/components/follow_button.tsx
@@ -27,7 +27,7 @@ const messages = defineMessages({
 });
 
 export const FollowButton: React.FC<{
-  accountId: string;
+  accountId?: string;
 }> = ({ accountId }) => {
   const intl = useIntl();
   const dispatch = useAppDispatch();
@@ -36,7 +36,7 @@ export const FollowButton: React.FC<{
     accountId ? state.accounts.get(accountId) : undefined,
   );
   const relationship = useAppSelector((state) =>
-    state.relationships.get(accountId),
+    accountId ? state.relationships.get(accountId) : undefined,
   );
   const following = relationship?.following || relationship?.requested;
 
diff --git a/app/javascript/mastodon/components/hover_card_account.tsx b/app/javascript/mastodon/components/hover_card_account.tsx
index 59f9577838..8933e14a98 100644
--- a/app/javascript/mastodon/components/hover_card_account.tsx
+++ b/app/javascript/mastodon/components/hover_card_account.tsx
@@ -17,7 +17,7 @@ import { useAppSelector, useAppDispatch } from 'mastodon/store';
 
 export const HoverCardAccount = forwardRef<
   HTMLDivElement,
-  { accountId: string }
+  { accountId?: string }
 >(({ accountId }, ref) => {
   const dispatch = useAppDispatch();
 
diff --git a/app/javascript/mastodon/components/hover_card_controller.tsx b/app/javascript/mastodon/components/hover_card_controller.tsx
index 0130390ef8..5ca55ebde9 100644
--- a/app/javascript/mastodon/components/hover_card_controller.tsx
+++ b/app/javascript/mastodon/components/hover_card_controller.tsx
@@ -12,8 +12,8 @@ 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 enterDelay = 750;
+const leaveDelay = 150;
 const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
 
 const isHoverCardAnchor = (element: HTMLElement) =>
@@ -23,50 +23,12 @@ 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 cardRef = useRef<HTMLDivElement | null>(null);
   const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
-  const [setEnterTimeout, cancelEnterTimeout] = useTimeout();
+  const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout();
+  const [setScrollTimeout] = 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();
@@ -79,22 +41,119 @@ export const HoverCardController: React.FC = () => {
   }, [handleClose, location]);
 
   useEffect(() => {
-    document.body.addEventListener('mouseenter', handleAnchorMouseEnter, {
+    let isScrolling = false;
+    let currentAnchor: HTMLElement | null = null;
+
+    const open = (target: HTMLElement) => {
+      target.setAttribute('aria-describedby', 'hover-card');
+      setOpen(true);
+      setAnchor(target);
+      setAccountId(target.getAttribute('data-hover-card-account') ?? undefined);
+    };
+
+    const close = () => {
+      currentAnchor?.removeAttribute('aria-describedby');
+      currentAnchor = null;
+      setOpen(false);
+      setAnchor(null);
+      setAccountId(undefined);
+    };
+
+    const handleMouseEnter = (e: MouseEvent) => {
+      const { target } = e;
+
+      // We've exited the window
+      if (!(target instanceof HTMLElement)) {
+        close();
+        return;
+      }
+
+      // We've entered an anchor
+      if (!isScrolling && isHoverCardAnchor(target)) {
+        cancelLeaveTimeout();
+
+        currentAnchor?.removeAttribute('aria-describedby');
+        currentAnchor = target;
+
+        setEnterTimeout(() => {
+          open(target);
+        }, enterDelay);
+      }
+
+      // We've entered the hover card
+      if (
+        !isScrolling &&
+        (target === currentAnchor || target === cardRef.current)
+      ) {
+        cancelLeaveTimeout();
+      }
+    };
+
+    const handleMouseLeave = (e: MouseEvent) => {
+      if (!currentAnchor) {
+        return;
+      }
+
+      if (e.target === currentAnchor || e.target === cardRef.current) {
+        cancelEnterTimeout();
+
+        setLeaveTimeout(() => {
+          close();
+        }, leaveDelay);
+      }
+    };
+
+    const handleScrollEnd = () => {
+      isScrolling = false;
+    };
+
+    const handleScroll = () => {
+      isScrolling = true;
+      cancelEnterTimeout();
+      setScrollTimeout(handleScrollEnd, 100);
+    };
+
+    const handleMouseMove = () => {
+      delayEnterTimeout(enterDelay);
+    };
+
+    document.body.addEventListener('mouseenter', handleMouseEnter, {
       passive: true,
       capture: true,
     });
-    document.body.addEventListener('mouseleave', handleAnchorMouseLeave, {
+
+    document.body.addEventListener('mousemove', handleMouseMove, {
+      passive: true,
+      capture: false,
+    });
+
+    document.body.addEventListener('mouseleave', handleMouseLeave, {
+      passive: true,
+      capture: true,
+    });
+
+    document.addEventListener('scroll', handleScroll, {
       passive: true,
       capture: true,
     });
 
     return () => {
-      document.body.removeEventListener('mouseenter', handleAnchorMouseEnter);
-      document.body.removeEventListener('mouseleave', handleAnchorMouseLeave);
+      document.body.removeEventListener('mouseenter', handleMouseEnter);
+      document.body.removeEventListener('mousemove', handleMouseMove);
+      document.body.removeEventListener('mouseleave', handleMouseLeave);
+      document.removeEventListener('scroll', handleScroll);
     };
-  }, [handleAnchorMouseEnter, handleAnchorMouseLeave]);
-
-  if (!accountId) return null;
+  }, [
+    setEnterTimeout,
+    setLeaveTimeout,
+    setScrollTimeout,
+    cancelEnterTimeout,
+    cancelLeaveTimeout,
+    delayEnterTimeout,
+    setOpen,
+    setAccountId,
+    setAnchor,
+  ]);
 
   return (
     <Overlay
diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx
index 3af89f9974..a2b72f7162 100644
--- a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx
+++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx
@@ -163,7 +163,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
   menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete });
 
   const names = accounts.map(a => (
-    <Link to={`/@${a.get('acct')}`} key={a.get('id')} title={a.get('acct')}>
+    <Link to={`/@${a.get('acct')}`} key={a.get('id')} data-hover-card-account={a.get('id')}>
       <bdi>
         <strong
           className='display-name__html'
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index cbf9314ff8..12eac79b98 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -10468,12 +10468,14 @@ noscript {
         overflow: hidden;
         white-space: nowrap;
         text-overflow: ellipsis;
+        text-align: end;
       }
 
       &.verified {
         dd {
           display: flex;
           align-items: center;
+          justify-content: flex-end;
           gap: 4px;
           overflow: hidden;
           white-space: nowrap;