Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/web/e2e/app-flows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ test('stops following the newest edge when the reader scrolls upward', async ({
await expect(scroller).toHaveAttribute('data-resume-anchor', 'newest');

const readerPosition = await scroller.evaluate((element) => {
element.dispatchEvent(new WheelEvent('wheel', { deltaY: -40, bubbles: true }));
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 40);
element.dispatchEvent(new Event('scroll'));
return element.scrollTop;
});

Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/components/MessageList.scroll.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,49 @@ describe('MessageList channel scroll restoration', () => {
expect(scroller.scrollTop).toBe(760);
});

it('releases bottom-follow on upward wheel intent before the scroll event', () => {
const initial = props({
resumePosition: null,
messages: [message('message-40')],
});
const { container, rerender } = render(<MessageList {...initial} />);
const scroller = container.querySelector<HTMLElement>('.msg-scroll')!;

// Browsers can materialize content-visibility rows and notify ResizeObserver
// after wheel input but before dispatching scroll. Bottom-follow must already
// be released during that interval.
fireEvent.wheel(scroller, { deltaY: -40 });
scroller.scrollTop = 760;
rerender(<MessageList {...initial} messages={[message('message-40'), message('message-41')]} />);

expect(scroller.scrollTop).toBe(760);
});

it('releases bottom-follow on upward touch intent before the scroll event', () => {
const initial = props({ resumePosition: null, messages: [message('message-40')] });
const { container, rerender } = render(<MessageList {...initial} />);
const scroller = container.querySelector<HTMLElement>('.msg-scroll')!;

fireEvent.touchStart(scroller, { touches: [{ clientY: 100 }] });
fireEvent.touchMove(scroller, { touches: [{ clientY: 140 }] });
scroller.scrollTop = 760;
rerender(<MessageList {...initial} messages={[message('message-40'), message('message-41')]} />);

expect(scroller.scrollTop).toBe(760);
});

it('releases bottom-follow on keyboard history navigation', () => {
const initial = props({ resumePosition: null, messages: [message('message-40')] });
const { container, rerender } = render(<MessageList {...initial} />);
const scroller = container.querySelector<HTMLElement>('.msg-scroll')!;

fireEvent.keyDown(window, { key: 'PageUp' });
scroller.scrollTop = 760;
rerender(<MessageList {...initial} messages={[message('message-40'), message('message-41')]} />);

expect(scroller.scrollTop).toBe(760);
});

it('captures the visible anchor synchronously before switching channels', () => {
const onScrollPosition = vi.fn();
const scrollCaptureRef = { current: null as (() => void) | null };
Expand Down
54 changes: 49 additions & 5 deletions apps/web/src/components/MessageList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { memo, useLayoutEffect, useRef } from 'react';
import type { MutableRefObject, ReactNode } from 'react';
import type {
MutableRefObject,
ReactNode,
TouchEvent as ReactTouchEvent,
WheelEvent as ReactWheelEvent,
} from 'react';
import type { Message } from '../lib/types';
import { MessageRow } from './MessageRow';
import { OpenChatSpinner } from './OpenChatSpinner';
Expand Down Expand Up @@ -53,6 +58,7 @@ function MessageListInner(props: MessageListProps) {
const wasLoadingNewer = useRef(false);
const followNewest = useRef(true);
const lastScrollTop = useRef(0);
const lastTouchY = useRef<number | null>(null);
const restoreFrames = useRef<number[]>([]);

function scrollToNewest(el: HTMLDivElement) {
Expand All @@ -65,9 +71,45 @@ function MessageListInner(props: MessageListProps) {
restoreFrames.current = [];
}

useLayoutEffect(() => () => {
for (const frame of restoreFrames.current) cancelAnimationFrame(frame);
restoreFrames.current = [];
function releaseBottomFollow() {
followNewest.current = false;
cancelRestoreFrames();
}

function onWheelIntent(event: ReactWheelEvent<HTMLDivElement>) {
if (event.deltaY < 0) releaseBottomFollow();
}

function onTouchStart(event: ReactTouchEvent<HTMLDivElement>) {
lastTouchY.current = event.touches[0]?.clientY ?? null;
}

function onTouchMove(event: ReactTouchEvent<HTMLDivElement>) {
const nextY = event.touches[0]?.clientY ?? null;
if (nextY != null && lastTouchY.current != null && nextY > lastTouchY.current + 1) {
releaseBottomFollow();
}
lastTouchY.current = nextY;
}

function onTouchEnd() {
lastTouchY.current = null;
}

useLayoutEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const target = event.target;
if (target instanceof Element && target.closest('input, textarea, [contenteditable="true"]')) return;
if (event.key === 'ArrowUp' || event.key === 'PageUp' || event.key === 'Home') {
releaseBottomFollow();
}
};
window.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
for (const frame of restoreFrames.current) cancelAnimationFrame(frame);
restoreFrames.current = [];
};
}, []);

function visibleBounds() {
Expand Down Expand Up @@ -245,7 +287,9 @@ function MessageListInner(props: MessageListProps) {
}, [messages, channelId, resumePosition, loadingNewer]);

return (
<div ref={scrollRef} onScroll={onScroll} className="msg-scroll"
<div ref={scrollRef} onScroll={onScroll} onWheel={onWheelIntent}
onTouchStart={onTouchStart} onTouchMove={onTouchMove} onTouchEnd={onTouchEnd}
className="msg-scroll"
data-resume-anchor={resumePosition === undefined ? 'loading' : (resumePosition?.messageId ?? 'newest')}
style={{ flex: 1, overflowY: 'auto', padding: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
{loadingOlder && (
Expand Down
Loading