diff --git a/resources/js/components/chat/conversation-thread.tsx b/resources/js/components/chat/conversation-thread.tsx index 85b9cf69b..9071c9dc1 100644 --- a/resources/js/components/chat/conversation-thread.tsx +++ b/resources/js/components/chat/conversation-thread.tsx @@ -75,7 +75,7 @@ import { toggleReaction, } from '@/lib/chat'; import type { ChatTarget, PendingMessage } from '@/lib/chat'; -import { isBehindLatest } from '@/lib/chat-scroll'; +import { glideToPresent, isBehindLatest } from '@/lib/chat-scroll'; import { formatCount } from '@/lib/numbers'; import { pullsOf, statusMessageOf } from '@/lib/pulls'; import { doubleTapReaction } from '@/lib/reactions'; @@ -187,8 +187,11 @@ export function ConversationThread({ const [isScrolledBack, setIsScrolledBack] = useState(false); const [isJumping, setIsJumping] = useState(false); const jumpRequest = useRef(null); + const glideCancel = useRef<(() => void) | null>(null); + const isGliding = useRef(false); const messages = thread?.messages ?? noMessages; + const messagesRef = useRef(messages); const pending = thread?.pending ?? noPending; const latestId = messages.at(-1)?.id ?? 0; const head = thread?.head ?? 0; @@ -374,11 +377,20 @@ export function ConversationThread({ [user, current, members, messages, pinnedAuthors], ); - useEffect(() => () => jumpRequest.current?.abort(), []); + useEffect( + () => () => { + jumpRequest.current?.abort(); + glideCancel.current?.(); + }, + [], + ); const jumpTo = useCallback( async (id: number) => { jumpRequest.current?.abort(); + glideCancel.current?.(); + glideCancel.current = null; + isGliding.current = false; const request = new AbortController(); jumpRequest.current = request; anchor.current = null; @@ -483,6 +495,10 @@ export function ConversationThread({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isVisible, latestId, thread?.isLoaded, targetKey(target)]); + useEffect(() => { + messagesRef.current = messages; + }, [messages]); + useLayoutEffect(() => { const element = scroller.current; @@ -517,7 +533,11 @@ export function ConversationThread({ // Confirming the same optimistic reply must not interrupt its scroll. seenLatest.current = latestId; } else if (isPinned.current) { - element.scrollTop = 0; + // A glide already owns the viewport; it still keeps the seen pointer current. + if (!isGliding.current) { + element.scrollTop = 0; + } + seenLatest.current = latestId; } else { if (anchor.current !== null) { @@ -542,14 +562,41 @@ export function ConversationThread({ useLayoutEffect(releasePinClearance, [releasePinClearance]); const releaseScrollAnchor = useCallback(() => { + const element = scroller.current; + const wasGliding = isGliding.current; + // Let deliberate input own the viewport until the fold settles, including delayed automatic scroll events. anchor.current = null; sentAnchor.current = null; isFollowingSend.current = false; jumpRequest.current?.abort(); jumpRequest.current = null; + glideCancel.current?.(); + glideCancel.current = null; + isGliding.current = false; setIsJumping(false); - }, []); + + if (!wasGliding || element === null) { + return; + } + + // A cancel can land before the first frame, so trust the scroller rather than the state the glide was started with. + isPinned.current = isAtLatest(element); + + if (!isPinned.current) { + anchor.current = scrollAnchorOf(element); + } + + // The pinned branch may have advanced the seen pointer mid-glide without clearing the count. + setUnseen( + messagesRef.current.filter( + (message) => + message.id > seenLatest.current && + message.user_id !== user.id && + !message.event, + ).length, + ); + }, [user.id]); // A row below the visible ones can still change height, as reactions or a link card arrive, and the scroller itself // shrinks as the composer grows or the keyboard opens. Anchored at its bottom, either would move what is being read, @@ -618,8 +665,11 @@ export function ConversationThread({ return; } - updateScrollPosition(); - releasePinClearance(); + // A glide owns the viewport until it lands or the user takes over. + if (!isGliding.current) { + updateScrollPosition(); + releasePinClearance(); + } // A correction during the reply fold is not an intentional scroll, even while it is close to the bottom. if ( @@ -636,7 +686,11 @@ export function ConversationThread({ if (element.scrollTop === 0) { isFollowingSend.current = false; } - if (!isReplyResizing.current || anchor.current !== null) { + if ( + !isGliding.current && + (!isReplyResizing.current || anchor.current !== null) + ) { + // An anchor captured mid-glide would be restored by a later resize and fight the glide. anchor.current = scrollAnchorOf(element); } @@ -664,16 +718,35 @@ export function ConversationThread({ jumpRequest.current?.abort(); jumpRequest.current = null; setIsJumping(false); - setIsBrowsingHistory(false); - setIsScrolledBack(false); anchor.current = null; sentAnchor.current = null; isFollowingSend.current = false; restoredScrollTop.current = null; isPinned.current = true; + glideCancel.current?.(); + glideCancel.current = null; const element = scroller.current; - element?.scrollTo({ top: 0, behavior: 'instant' }); + if (element === null) { + isGliding.current = false; + setIsBrowsingHistory(false); + setIsScrolledBack(false); + + return; + } + + // Keeping the shortcut visible until the glide lands avoids a blink at the start. + isGliding.current = true; + glideCancel.current = glideToPresent(element, { + onDone: () => { + isGliding.current = false; + setUnseen(0); + setIsBrowsingHistory(false); + setIsScrolledBack(false); + updateScrollPosition(); + releasePinClearance(); + }, + }); }; return ( @@ -738,7 +811,10 @@ export function ConversationThread({ } }} onPointerDown={(event) => { - if (event.target === event.currentTarget) { + if ( + isGliding.current || + event.target === event.currentTarget + ) { releaseScrollAnchor(); } }} diff --git a/resources/js/lib/chat-scroll.ts b/resources/js/lib/chat-scroll.ts index 379875bdd..d42d5b762 100644 --- a/resources/js/lib/chat-scroll.ts +++ b/resources/js/lib/chat-scroll.ts @@ -1,5 +1,78 @@ import type { Message } from '@/types'; +const MIN_GLIDE_MS = 180; +const MAX_GLIDE_MS = 500; + +/** + * The scroll position of a glide to the present, eased out so the motion + * settles instead of stopping dead. `start` is negative in the reversed + * scroller, and `progress` runs from 0 to 1. + */ +export function presentOffset(start: number, progress: number): number { + // Frame timestamps can predate the start time, and a late frame can overshoot 1. + const clamped = Math.min(1, Math.max(0, progress)); + + // `Math.round` can hand back `-0`, which callers should never see. + return Math.round(start * (1 - clamped) ** 3) || 0; +} + +/** + * Long conversations should not turn the glide into a slow ride, so the + * duration grows with the square root of the distance and then stops. + */ +export function glideDuration(distance: number): number { + return Math.min( + MAX_GLIDE_MS, + MIN_GLIDE_MS + Math.sqrt(Math.abs(distance)) * 2.5, + ); +} + +/** + * Glides the scroller back to the present, returning a cancel function. + * Respects the reduced-motion preference. + */ +export function glideToPresent( + scroller: HTMLElement, + options: { duration?: number; onDone?: () => void } = {}, +): () => void { + const start = scroller.scrollTop; + + if (start === 0) { + options.onDone?.(); + + return () => {}; + } + + if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { + scroller.scrollTop = 0; + options.onDone?.(); + + return () => {}; + } + + const duration = options.duration ?? glideDuration(start); + const startedAt = performance.now(); + let frame = 0; + + const step = (now: number) => { + // The frame timestamp can predate `startedAt`, which `presentOffset` clamps. + const progress = Math.min(1, (now - startedAt) / duration); + scroller.scrollTop = presentOffset(start, progress); + + if (progress < 1) { + frame = requestAnimationFrame(step); + + return; + } + + options.onDone?.(); + }; + + frame = requestAnimationFrame(step); + + return () => cancelAnimationFrame(frame); +} + export function isBehindLatest( scroller: HTMLElement, messages: Message[], diff --git a/tests/Unit/ChatScroll.test.ts b/tests/Unit/ChatScroll.test.ts index fe73a14ed..252914851 100644 --- a/tests/Unit/ChatScroll.test.ts +++ b/tests/Unit/ChatScroll.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import { after, test } from 'node:test'; -import { isBehindLatest } from '../../resources/js/lib/chat-scroll.ts'; +import { + glideDuration, + isBehindLatest, + presentOffset, +} from '../../resources/js/lib/chat-scroll.ts'; import type { Message } from '../../resources/js/types/index.ts'; const originalCSS = globalThis.CSS; @@ -59,3 +63,34 @@ void test('offers the present when an image-only message is the newest-25 bounda void test('keeps the shortcut hidden while a boundary image is visible above its text caption', () => { assert.equal(isBehindLatest(scroller({ 6: 599 }, 600, { 6: 650 }), messages(30)), false); }); + +void test('eases the glide out so it settles on the present', () => { + assert.equal(presentOffset(-800, 0), -800); + assert.equal(presentOffset(-800, 0.5), -100); + assert.equal(presentOffset(-800, 1), 0); +}); + +void test('never moves backwards on the way to the present', () => { + let previous = -5000; + + for (let step = 0; step <= 20; step += 1) { + const current = presentOffset(-5000, step / 20); + + assert.ok(current >= previous); + previous = current; + } + + assert.equal(previous, 0); +}); + +void test('keeps long glides short instead of scrolling for seconds', () => { + assert.equal(glideDuration(0), 180); + assert.ok(glideDuration(400) > 180); + assert.ok(glideDuration(20_000) > glideDuration(2_000)); + assert.equal(glideDuration(1_000_000), 500); +}); + +void test('clamps a frame that lands before the start or past the end', () => { + assert.equal(presentOffset(-800, -0.03), -800); + assert.equal(presentOffset(-800, 1.4), 0); +});