diff --git a/resources/js/components/chat/conversation-thread.tsx b/resources/js/components/chat/conversation-thread.tsx index 8c78cfb87..98ac2f3d3 100644 --- a/resources/js/components/chat/conversation-thread.tsx +++ b/resources/js/components/chat/conversation-thread.tsx @@ -1312,7 +1312,16 @@ 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..cf76eecfe 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'; @@ -57,6 +58,8 @@ vi.doMock('../../resources/js/hooks/use-link-card', () => ({ })); 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 []; @@ -68,9 +71,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 +137,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 +165,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 +194,7 @@ function setup(reducedMotion = false, initialScrollTop = 0) { scroller, scrollTo, writes, + nodes: () => render().nodes, reply: initial.reply, resize: () => { rowTop += 150; @@ -282,3 +294,92 @@ 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, 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 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: caption ? 'Release dashboard ready for review.' : '', + 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) + .map((node) => ({ + node, + dataset: {} as Record, + scrollIntoView: vi.fn(), + })); + const load = vi.spyOn(chatStore, 'loadMessage').mockResolvedValue(true); + chat.scroller.querySelector = (selector: string) => + !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(), + ); + const timers: (() => void)[] = []; + Object.assign(window, { + setTimeout: (callback: () => void) => timers.push(callback), + }); + + await (group.props.onJump as (id: number) => Promise)(1); + + 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, ''); + assert.equal(load.mock.calls.length, paginated ? 1 : 0); + timers.forEach((callback) => callback()); + assert.equal(original.dataset.flash, undefined); + }, +);