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 }),