Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion resources/js/components/chat/conversation-thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1312,7 +1312,16 @@ function BubbleWithMeta({
/>
)}
{images.length > 0 && (
<div className="relative max-w-full">
<div
data-message-id={
bubble.kind === 'message' &&
message.body === '' &&
voice === null
? bubble.message.id
: undefined
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
className="relative max-w-full rounded-lg ring-primary ring-offset-2 ring-offset-background transition-shadow duration-200 data-flash:ring-2 motion-reduce:transition-none"
>
<MessageImages images={images} />
{message.body === '' &&
voice === null &&
Expand Down
115 changes: 108 additions & 7 deletions tests/Unit/ChatScroll.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<Record<string, unknown>>;
function elements(node: ReactNode): Element[] {
if (!React.isValidElement(node)) return [];
Expand All @@ -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,
Expand Down Expand Up @@ -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 = [];
Expand All @@ -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',
Expand Down Expand Up @@ -183,6 +194,7 @@ function setup(reducedMotion = false, initialScrollTop = 0) {
scroller,
scrollTo,
writes,
nodes: () => render().nodes,
reply: initial.reply,
resize: () => {
rowTop += 150;
Expand Down Expand Up @@ -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<string, string>,
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<void>)(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);
},
);
Loading