[Glitch] Change hover cards to not appear until the mouse stops in web UI

Port b728c0e8ce to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
Eugen Rochko 2024-07-01 17:52:01 +02:00 committed by Claire
parent 92dcc50278
commit dcfd39991b
6 changed files with 131 additions and 55 deletions

View File

@ -26,7 +26,7 @@ const messages = defineMessages({
}); });
export const FollowButton: React.FC<{ export const FollowButton: React.FC<{
accountId: string; accountId?: string;
}> = ({ accountId }) => { }> = ({ accountId }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -35,7 +35,7 @@ export const FollowButton: React.FC<{
accountId ? state.accounts.get(accountId) : undefined, accountId ? state.accounts.get(accountId) : undefined,
); );
const relationship = useAppSelector((state) => const relationship = useAppSelector((state) =>
state.relationships.get(accountId), accountId ? state.relationships.get(accountId) : undefined,
); );
const following = relationship?.following || relationship?.requested; const following = relationship?.following || relationship?.requested;

View File

@ -17,7 +17,7 @@ import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
export const HoverCardAccount = forwardRef< export const HoverCardAccount = forwardRef<
HTMLDivElement, HTMLDivElement,
{ accountId: string } { accountId?: string }
>(({ accountId }, ref) => { >(({ accountId }, ref) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();

View File

@ -12,8 +12,8 @@ import { HoverCardAccount } from 'flavours/glitch/components/hover_card_account'
import { useTimeout } from 'flavours/glitch/hooks/useTimeout'; import { useTimeout } from 'flavours/glitch/hooks/useTimeout';
const offset = [-12, 4] as OffsetValue; const offset = [-12, 4] as OffsetValue;
const enterDelay = 650; const enterDelay = 750;
const leaveDelay = 250; const leaveDelay = 150;
const popperConfig = { strategy: 'fixed' } as UsePopperOptions; const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
const isHoverCardAnchor = (element: HTMLElement) => const isHoverCardAnchor = (element: HTMLElement) =>
@ -23,50 +23,12 @@ export const HoverCardController: React.FC = () => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [accountId, setAccountId] = useState<string | undefined>(); const [accountId, setAccountId] = useState<string | undefined>();
const [anchor, setAnchor] = useState<HTMLElement | null>(null); const [anchor, setAnchor] = useState<HTMLElement | null>(null);
const cardRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement | null>(null);
const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout(); const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
const [setEnterTimeout, cancelEnterTimeout] = useTimeout(); const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout();
const [setScrollTimeout] = useTimeout();
const location = useLocation(); 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(() => { const handleClose = useCallback(() => {
cancelEnterTimeout(); cancelEnterTimeout();
cancelLeaveTimeout(); cancelLeaveTimeout();
@ -79,22 +41,119 @@ export const HoverCardController: React.FC = () => {
}, [handleClose, location]); }, [handleClose, location]);
useEffect(() => { 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, passive: true,
capture: 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, passive: true,
capture: true, capture: true,
}); });
return () => { return () => {
document.body.removeEventListener('mouseenter', handleAnchorMouseEnter); document.body.removeEventListener('mouseenter', handleMouseEnter);
document.body.removeEventListener('mouseleave', handleAnchorMouseLeave); document.body.removeEventListener('mousemove', handleMouseMove);
document.body.removeEventListener('mouseleave', handleMouseLeave);
document.removeEventListener('scroll', handleScroll);
}; };
}, [handleAnchorMouseEnter, handleAnchorMouseLeave]); }, [
setEnterTimeout,
if (!accountId) return null; setLeaveTimeout,
setScrollTimeout,
cancelEnterTimeout,
cancelLeaveTimeout,
delayEnterTimeout,
setOpen,
setAccountId,
setAnchor,
]);
return ( return (
<Overlay <Overlay

View File

@ -185,7 +185,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete }); menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete });
const names = accounts.map(a => ( const names = accounts.map(a => (
<Permalink to={`/@${a.get('acct')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}> <Permalink to={`/@${a.get('acct')}`} href={a.get('url')} key={a.get('id')} data-hover-card-account={a.get('id')}>
<bdi> <bdi>
<strong <strong
className='display-name__html' className='display-name__html'

View File

@ -2,19 +2,34 @@ import { useRef, useCallback, useEffect } from 'react';
export const useTimeout = () => { export const useTimeout = () => {
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(); const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const callbackRef = useRef<() => void>();
const set = useCallback((callback: () => void, delay: number) => { const set = useCallback((callback: () => void, delay: number) => {
if (timeoutRef.current) { if (timeoutRef.current) {
clearTimeout(timeoutRef.current); clearTimeout(timeoutRef.current);
} }
callbackRef.current = callback;
timeoutRef.current = setTimeout(callback, delay); 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(() => { const cancel = useCallback(() => {
if (timeoutRef.current) { if (timeoutRef.current) {
clearTimeout(timeoutRef.current); clearTimeout(timeoutRef.current);
timeoutRef.current = undefined; timeoutRef.current = undefined;
callbackRef.current = undefined;
} }
}, []); }, []);
@ -25,5 +40,5 @@ export const useTimeout = () => {
[cancel], [cancel],
); );
return [set, cancel] as const; return [set, cancel, delay] as const;
}; };

View File

@ -11022,12 +11022,14 @@ noscript {
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
text-align: end;
} }
&.verified { &.verified {
dd { dd {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end;
gap: 4px; gap: 4px;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;