diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index 0f0a15340b..fc92d4a6e3 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -48,6 +48,7 @@ function Conversation({ const [titleInput, setTitleInput] = useState(title || ''); const [renaming, setRenaming] = useState(false); const [isPopoverActive, setIsPopoverActive] = useState(false); + const [isHovered, setIsHovered] = useState(false); // Lazy-load ConvoOptions to avoid running heavy hooks for all conversations const [hasInteracted, setHasInteracted] = useState(false); @@ -222,6 +223,13 @@ function Conversation({ ? 'bg-surface-active-alt before:absolute before:bottom-1 before:left-0 before:top-1 before:w-0.5 before:rounded-full before:bg-text-primary' : 'hover:bg-surface-active-alt', )} + onPointerEnter={(event) => { + if (event.pointerType === 'mouse') { + setIsHovered(true); + } + }} + onPointerLeave={() => setIsHovered(false)} + onPointerCancel={() => setIsHovered(false)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onFocus={handleMouseEnter} @@ -249,6 +257,7 @@ function Conversation({ void; isSmallScreen: boolean; - localize: (key: any, options?: any) => string; + localize: (key: TranslationKeys, options?: TOptions) => string; children: React.ReactNode; } +const TITLE_SPEED_PX_PER_SECOND = 30; +const TITLE_START_DELAY_MS = 600; +const TITLE_HOLD_MS = 2000; + +/** Legacy engines expose only `addListener` on MediaQueryList. */ +const observeMediaQuery = (query: MediaQueryList, onChange: () => void) => { + if (typeof query.addEventListener === 'function') { + query.addEventListener('change', onChange); + return () => query.removeEventListener('change', onChange); + } + + query.addListener(onChange); + return () => query.removeListener(onChange); +}; + const ConvoLink: React.FC = ({ isActiveConvo, isPopoverActive, + isHovered, isSharedBadgeVisible, title, onRename, @@ -22,14 +41,90 @@ const ConvoLink: React.FC = ({ localize, children, }) => { + const [isOverflowing, setIsOverflowing] = useState(false); + const titleRef = useRef(null); + const textRef = useRef(null); + const displayTitle = title || localize('com_ui_untitled'); + + useEffect(() => { + const viewport = titleRef.current; + const text = textRef.current; + if (!viewport || !text) { + return; + } + + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + let animation: Animation | undefined; + let frame = 0; + + const start = () => { + animation?.cancel(); + cancelAnimationFrame(frame); + delete viewport.dataset.titleRevealed; + const overflow = text.getBoundingClientRect().width - viewport.getBoundingClientRect().width; + setIsOverflowing(overflow > 0); + if (!isHovered || isSmallScreen || reducedMotion.matches || overflow <= 0) { + return; + } + + const style = getComputedStyle(viewport); + const direction = style.direction === 'rtl' ? 1 : -1; + const revealDuration = parseFloat(style.getPropertyValue('--convo-title-reveal-duration')); + const travelDuration = (overflow / TITLE_SPEED_PX_PER_SECOND) * 1000; + const duration = travelDuration + TITLE_HOLD_MS; + const end = `translateX(${direction * overflow}px)`; + + animation = text.animate( + [ + { transform: 'translateX(0)' }, + { transform: end, offset: travelDuration / duration }, + { transform: end }, + ], + { + duration, + delay: TITLE_START_DELAY_MS, + iterations: Infinity, + easing: 'linear', + }, + ); + + /** The fade retreats as the ending arrives, so the last characters read + * clearly without the text overscrolling past the available width. CSS owns + * the mask because Web Animations cannot carry the `-webkit-` fallback that + * older WebKit needs. */ + const revealFrom = Math.max(0, travelDuration - revealDuration); + const tick = () => { + const currentTime = typeof animation?.currentTime === 'number' ? animation.currentTime : 0; + const elapsed = currentTime - TITLE_START_DELAY_MS; + viewport.dataset.titleRevealed = String(elapsed > 0 && elapsed % duration >= revealFrom); + frame = requestAnimationFrame(tick); + }; + + tick(); + }; + + const observer = new ResizeObserver(start); + observer.observe(viewport); + observer.observe(text); + const unobserveMediaQuery = observeMediaQuery(reducedMotion, start); + start(); + + return () => { + observer.disconnect(); + unobserveMediaQuery(); + animation?.cancel(); + cancelAnimationFrame(frame); + delete viewport.dataset.titleRevealed; + }; + }, [isHovered, isSmallScreen, displayTitle]); + return ( ); };