Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 87 additions & 11 deletions resources/js/components/chat/conversation-thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -187,8 +187,11 @@ export function ConversationThread({
const [isScrolledBack, setIsScrolledBack] = useState(false);
const [isJumping, setIsJumping] = useState(false);
const jumpRequest = useRef<AbortController | null>(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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
seenLatest.current = latestId;
} else {
if (anchor.current !== null) {
Expand All @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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,
Expand Down Expand Up @@ -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();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// A correction during the reply fold is not an intentional scroll, even while it is close to the bottom.
if (
Expand All @@ -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);
}

Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -738,7 +811,10 @@ export function ConversationThread({
}
}}
onPointerDown={(event) => {
if (event.target === event.currentTarget) {
if (
isGliding.current ||
event.target === event.currentTarget
) {
releaseScrollAnchor();
}
}}
Expand Down
73 changes: 73 additions & 0 deletions resources/js/lib/chat-scroll.ts
Original file line number Diff line number Diff line change
@@ -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[],
Expand Down
37 changes: 36 additions & 1 deletion tests/Unit/ChatScroll.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
});