From 3f9bd65c1d7f5d34746028a541c0fe37eca50f7c Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 30 Oct 2026 12:05:35 +0100 Subject: [PATCH 1/2] fix: jump to image-only message replies --- .../components/chat/conversation-thread.tsx | 9 +- tests/Unit/ChatScroll.test.tsx | 105 ++++++++++++++++-- 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/resources/js/components/chat/conversation-thread.tsx b/resources/js/components/chat/conversation-thread.tsx index 8c78cfb87..9384a8603 100644 --- a/resources/js/components/chat/conversation-thread.tsx +++ b/resources/js/components/chat/conversation-thread.tsx @@ -1312,7 +1312,14 @@ function BubbleWithMeta({ /> )} {images.length > 0 && ( -
+
{message.body === '' && voice === null && diff --git a/tests/Unit/ChatScroll.test.tsx b/tests/Unit/ChatScroll.test.tsx index 542c2b8a2..a4047f31d 100644 --- a/tests/Unit/ChatScroll.test.tsx +++ b/tests/Unit/ChatScroll.test.tsx @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import * as React from 'react'; import type { ReactElement, ReactNode } from 'react'; import { afterEach, test, vi } from 'vite-plus/test'; +import * as chatStore from '../../resources/js/lib/chat'; import type { ChatTarget } from '../../resources/js/lib/chat'; import type { Message, User } from '../../resources/js/types'; @@ -68,9 +69,16 @@ function elements(node: ReactNode): Element[] { ), ]; } -afterEach(() => vi.unstubAllGlobals()); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); -function setup(reducedMotion = false, initialScrollTop = 0) { +function setup( + reducedMotion = false, + initialScrollTop = 0, + kind: 'direct' | 'org' = 'org', +) { slots = []; thread = { isLoaded: true, @@ -127,16 +135,16 @@ function setup(reducedMotion = false, initialScrollTop = 0) { }, getBoundingClientRect: () => ({ top: 0 }), querySelectorAll: () => [row], - querySelector: () => row, + querySelector: (_selector: string): unknown => row, scrollTo, addEventListener: vi.fn(), removeEventListener: vi.fn(), }; const user = { id: 'me' } as User; - const target: ChatTarget = { - kind: 'org', - org: { id: 'org', name: 'Demo' }, - }; + const target: ChatTarget = + kind === 'direct' + ? { kind, partner: { id: 'partner', handle: 'demo' } as User } + : { kind, org: { id: 'org', name: 'Demo' } }; const render = () => { cursor = 0; layouts = []; @@ -155,6 +163,7 @@ function setup(reducedMotion = false, initialScrollTop = 0) { for (const effect of layouts) effect(); for (const effect of effects) effect(); return { + nodes, scroll, reply: nodes.find( (node) => typeof node.props.onReply === 'function', @@ -183,6 +192,7 @@ function setup(reducedMotion = false, initialScrollTop = 0) { scroller, scrollTo, writes, + nodes: () => render().nodes, reply: initial.reply, resize: () => { rowTop += 150; @@ -282,3 +292,84 @@ test('settles a send near the latest message without a scroll animation', () => assert.equal(chat.scroller.scrollTop, 0); assert.equal(chat.scrollTo.mock.calls.length, 0); }); + +test.each([ + ['direct', false], + ['direct', true], + ['org', false], + ['org', true], +] as const)( + 'jumps to an image-only original in a %s chat (paginated: %s)', + async (kind, paginated) => { + const chat = setup(false, 0, kind); + thread.messages = [ + { + id: 1, + client_id: 'photo', + user_id: 'partner', + body: '', + created_at: new Date().toISOString(), + images: [ + { + id: 'photo', + url: '/photo.webp', + description: 'Build dashboard', + }, + ], + }, + ]; + const group = chat + .nodes() + .find( + (node) => + (node.props.item as { kind?: string } | undefined)?.kind === + 'group', + )!; + const renderGroup = ( + group.type as unknown as { + type: (props: typeof group.props) => ReactNode; + } + ).type; + const bubble = elements(renderGroup(group.props)).find( + (node) => node.props.bubble !== undefined, + )!; + const renderBubble = bubble.type as ( + props: typeof bubble.props, + ) => ReactNode; + const targets = elements(renderBubble(bubble.props)).filter( + (node) => node.props['data-message-id'] === 1, + ); + const scrollIntoView = vi.fn(); + const original = { + dataset: {} as Record, + scrollIntoView, + }; + const load = vi.spyOn(chatStore, 'loadMessage').mockResolvedValue(true); + chat.scroller.querySelector = (selector: string) => + targets.some( + (node) => + selector === + `[data-message-id="${String(node.props['data-message-id'])}"]`, + ) && + (!paginated || load.mock.calls.length > 0) + ? original + : null; + vi.stubGlobal('requestAnimationFrame', (callback: () => void) => + callback(), + ); + const timers: (() => void)[] = []; + Object.assign(window, { + setTimeout: (callback: () => void) => timers.push(callback), + }); + + await (group.props.onJump as (id: number) => Promise)(1); + + assert.deepEqual(scrollIntoView.mock.calls, [ + [{ block: 'center', behavior: 'instant' }], + ]); + assert.equal(original.dataset.flash, ''); + assert.equal(load.mock.calls.length, paginated ? 1 : 0); + timers.forEach((callback) => callback()); + assert.equal(original.dataset.flash, undefined); + }, +); From 4e242501a55b8c31aa49591dea9ba2d84c152350 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 30 Oct 2026 12:25:35 +0100 Subject: [PATCH 2/2] fix: preserve captioned message reply targets --- .../components/chat/conversation-thread.tsx | 4 +- tests/Unit/ChatScroll.test.tsx | 56 +++++++++++-------- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/resources/js/components/chat/conversation-thread.tsx b/resources/js/components/chat/conversation-thread.tsx index 9384a8603..98ac2f3d3 100644 --- a/resources/js/components/chat/conversation-thread.tsx +++ b/resources/js/components/chat/conversation-thread.tsx @@ -1314,7 +1314,9 @@ function BubbleWithMeta({ {images.length > 0 && (
({ })); const { ConversationThread } = await import('../../resources/js/components/chat/conversation-thread'); +const { MessageImages, MessageText } = + await import('../../resources/js/components/chat/message-parts'); type Element = ReactElement>; function elements(node: ReactNode): Element[] { if (!React.isValidElement(node)) return []; @@ -294,20 +296,24 @@ test('settles a send near the latest message without a scroll animation', () => }); test.each([ - ['direct', false], - ['direct', true], - ['org', false], - ['org', true], + ['direct', false, false], + ['direct', true, false], + ['org', false, false], + ['org', true, false], + ['direct', false, true], + ['direct', true, true], + ['org', false, true], + ['org', true, true], ] as const)( - 'jumps to an image-only original in a %s chat (paginated: %s)', - async (kind, paginated) => { + 'jumps to the quoted content in a %s chat (paginated: %s, caption: %s)', + async (kind, paginated, caption) => { const chat = setup(false, 0, kind); thread.messages = [ { id: 1, client_id: 'photo', user_id: 'partner', - body: '', + body: caption ? 'Release dashboard ready for review.' : '', created_at: new Date().toISOString(), images: [ { @@ -336,23 +342,21 @@ test.each([ const renderBubble = bubble.type as ( props: typeof bubble.props, ) => ReactNode; - const targets = elements(renderBubble(bubble.props)).filter( - (node) => node.props['data-message-id'] === 1, - ); - const scrollIntoView = vi.fn(); - const original = { - dataset: {} as Record, - scrollIntoView, - }; + const targets = elements(renderBubble(bubble.props)) + .filter((node) => node.props['data-message-id'] === 1) + .map((node) => ({ + node, + dataset: {} as Record, + scrollIntoView: vi.fn(), + })); const load = vi.spyOn(chatStore, 'loadMessage').mockResolvedValue(true); chat.scroller.querySelector = (selector: string) => - targets.some( - (node) => - selector === - `[data-message-id="${String(node.props['data-message-id'])}"]`, - ) && - (!paginated || load.mock.calls.length > 0) - ? original + !paginated || load.mock.calls.length > 0 + ? (targets.find( + ({ node }) => + selector === + `[data-message-id="${String(node.props['data-message-id'])}"]`, + ) ?? null) : null; vi.stubGlobal('requestAnimationFrame', (callback: () => void) => callback(), @@ -364,7 +368,13 @@ test.each([ await (group.props.onJump as (id: number) => Promise)(1); - assert.deepEqual(scrollIntoView.mock.calls, [ + const original = targets.find(({ node }) => + elements(node).some( + (child) => + child.type === (caption ? MessageText : MessageImages), + ), + )!; + assert.deepEqual(original.scrollIntoView.mock.calls, [ [{ block: 'center', behavior: 'instant' }], ]); assert.equal(original.dataset.flash, '');