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