From 21dc4a2ef490b86510e4b410fe8f78d52c1d9629 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 23 Jul 2026 11:48:45 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=AF=20fix:=20Correct=20Off-by-One=20Ra?= =?UTF-8?q?il=20Scrub=20After=20Pinning=20the=20Terminus=20(#14409)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinning the scroll-to-bottom rib moved it out of the column, but scrubTo kept enumerating ribs from the nav (messages + terminus) while measuring the fraction against the column, which now spans the messages alone. Every drag position mapped one rib late: pointing at the middle of the rail scrolled to the message below the rib under the cursor. Enumerate the column's own ribs for the proportional mapping and reach the terminus by dragging past the column's bottom edge, where it now sits. --- .../components/Chat/Messages/MessageNav.tsx | 16 +++-- .../Messages/__tests__/MessageNav.spec.tsx | 61 +++++++++++++++++++ 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/client/src/components/Chat/Messages/MessageNav.tsx b/client/src/components/Chat/Messages/MessageNav.tsx index 99d252c5c2..7ca8178a16 100644 --- a/client/src/components/Chat/Messages/MessageNav.tsx +++ b/client/src/components/Chat/Messages/MessageNav.tsx @@ -528,16 +528,22 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { const col = columnRef.current; - const nav = navRef.current; - if (!col || !nav) { + if (!col) { return; } - const ribs = nav.querySelectorAll('[data-msg-id]'); + const rect = col.getBoundingClientRect(); + /** The terminus is pinned below the column, so the pointer reaches it by + * travelling past the bottom edge — the proportional mapping covers only + * the ribs the column actually spans, or every position lands one late. */ + if (endEntry && clientY >= rect.bottom) { + scrollToImmediate(MESSAGES_END_ID); + return; + } + const ribs = col.querySelectorAll('[data-msg-id]'); const count = ribs.length; if (count === 0) { return; } - const rect = col.getBoundingClientRect(); const fraction = rect.height > 0 ? (clientY - rect.top) / rect.height : 0; const index = Math.max(0, Math.min(count - 1, Math.round(fraction * (count - 1)))); const id = ribs[index].getAttribute('data-msg-id'); @@ -545,7 +551,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { expect(column.scrollTop).toBe(13); }); + it('scrubs to the rib under the pointer, not one past it, with the terminus pinned', () => { + const messages = Array.from({ length: 5 }, (_, i) => + buildMessage({ messageId: `m-${i}`, text: `message ${i}`, isCreatedByUser: i % 2 === 0 }), + ); + const { container } = renderNavWithEnd(messages); + const column = container.querySelector('nav > div') as HTMLDivElement; + column.getBoundingClientRect = () => ({ top: 0, bottom: 50, height: 50 }) as DOMRect; + const getById = jest.spyOn(document, 'getElementById'); + + act(() => { + fireEvent.pointerDown(column, { pointerId: 1, button: 0, buttons: 1, clientY: 0 }); + fireEvent.pointerMove(document, { pointerId: 1, buttons: 1, clientY: 25 }); + }); + + const scrubbed = getById.mock.calls.map((c) => c[0]); + expect(scrubbed).toContain('m-2'); + expect(scrubbed).not.toContain('m-3'); + getById.mockRestore(); + }); + + it('peaks the fisheye and preview on the rib under the pointer', () => { + const messages = Array.from({ length: 6 }, (_, i) => + buildMessage({ messageId: `m-${i}`, text: `message ${i}` }), + ); + const asRect = (top: number, height: number): DOMRect => + ({ + top, + bottom: top + height, + height, + left: 200, + right: 214, + width: 14, + x: 200, + y: top, + toJSON: () => ({}), + }) as DOMRect; + /** Rib i occupies [i*12, i*12+6] — a 6px rib on a 6px gap. */ + const rectSpy = jest + .spyOn(Element.prototype, 'getBoundingClientRect') + .mockImplementation(function (this: Element) { + const id = this.getAttribute?.('data-msg-id'); + const index = id != null ? messages.findIndex((m) => m.messageId === id) : -1; + return index >= 0 ? asRect(index * 12, 6) : asRect(0, messages.length * 12); + }); + + const { container } = renderNavWithEnd(messages); + const column = container.querySelector('nav > div') as HTMLDivElement; + + act(() => { + fireEvent.pointerMove(column, { pointerId: 1, clientY: 3 * 12 + 3 }); + jest.advanceTimersByTime(80); + }); + + expect(document.body.querySelector('[role="tooltip"]')).toHaveTextContent('message 3'); + const highlighted = Array.from(container.querySelectorAll('[data-msg-id]')).filter((r) => + r.querySelector('span')?.className.includes('bg-gray-800'), + ); + expect(highlighted.map((r) => r.getAttribute('data-msg-id'))).toEqual(['m-3']); + rectSpy.mockRestore(); + }); + it('starts a scrub drag from the pinned terminus', () => { const messages = Array.from({ length: 5 }, (_, i) => buildMessage({ messageId: `m-${i}`, text: `message ${i}`, isCreatedByUser: i % 2 === 0 }),