mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-29 02:41:26 +00:00
🔚 feat: Add Bottom Terminus Node to Message Minimap Navigation (#13853)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
* ✨ feat: Add scroll-to-bottom terminus node to MessageNav Append the chat's bottom (#messages-end) as a terminal rib in the message minimap so it is reachable by click, drag-scrub, and the down chevron like any message. Rendered as a distinct centered dot rather than a line rib, and gated on the #messages-end sentinel actually existing. Also clamp each rib's snap target to the container's max scroll so the down chevron no longer stays stuck enabled at the bottom (the terminus can never scroll its top to the container top). * 🐛 fix: Scope MessageNav terminus to its own scroll container The terminus rib stored the shared constant id 'messages-end', which is rendered once per MessagesView. With multiple navs mounted, the global document.getElementById lookups resolved the first chat's sentinel, breaking the per-instance isolation guaranteed by the existing multi-instance tests. Resolve the terminus via the nav's own scrollableRef container (querySelector), leaving the globally-unique message ids on the fast getElementById path. Adds a multi-instance test covering the terminus.
This commit is contained in:
parent
8969034ad1
commit
f8aa45d05e
2 changed files with 310 additions and 76 deletions
|
|
@ -12,8 +12,11 @@ type MessageEntry = {
|
|||
id: string;
|
||||
isUser: boolean;
|
||||
preview: string;
|
||||
isEnd?: boolean;
|
||||
};
|
||||
|
||||
const MESSAGES_END_ID = 'messages-end';
|
||||
|
||||
function extractPreviewFromContent(content?: TMessageContentParts[]): string {
|
||||
if (!content) {
|
||||
return '';
|
||||
|
|
@ -67,6 +70,9 @@ function getMessageEntries(root: ParentNode, messagesById: Map<string, TMessage>
|
|||
const msg = messagesById.get(id);
|
||||
entries.push(msg ? buildEntry(id, msg) : buildFallbackEntry(node, id));
|
||||
}
|
||||
if (entries.length > 0 && root.querySelector('#' + MESSAGES_END_ID)) {
|
||||
entries.push({ id: MESSAGES_END_ID, isUser: false, preview: '', isEnd: true });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
|
|
@ -109,12 +115,14 @@ const MessageIndicator = memo(function MessageIndicator({
|
|||
isActive,
|
||||
isCurrent,
|
||||
label,
|
||||
hoverText,
|
||||
onSelect,
|
||||
}: {
|
||||
entry: MessageEntry;
|
||||
isActive: boolean;
|
||||
isCurrent: boolean;
|
||||
label: string;
|
||||
hoverText: string;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
|
|
@ -123,24 +131,35 @@ const MessageIndicator = memo(function MessageIndicator({
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(entry.id)}
|
||||
className={cn(indicatorButtonClasses, entry.isUser ? 'w-4' : 'w-6')}
|
||||
className={cn(indicatorButtonClasses, entry.isEnd || !entry.isUser ? 'w-6' : 'w-4')}
|
||||
aria-label={label}
|
||||
aria-current={isCurrent ? 'true' : undefined}
|
||||
data-msg-id={entry.id}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'block w-full rounded-full transition-all duration-200',
|
||||
isActive
|
||||
? 'h-[5px] bg-gray-800 dark:bg-gray-100'
|
||||
: 'h-[3px] bg-gray-400 dark:bg-gray-500',
|
||||
)}
|
||||
/>
|
||||
{entry.isEnd ? (
|
||||
<span
|
||||
className={cn(
|
||||
'block rounded-full transition-all duration-200',
|
||||
isActive
|
||||
? 'h-1.5 w-1.5 bg-gray-800 dark:bg-gray-100'
|
||||
: 'h-1 w-1 bg-gray-400 dark:bg-gray-500',
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'block w-full rounded-full transition-all duration-200',
|
||||
isActive
|
||||
? 'h-[5px] bg-gray-800 dark:bg-gray-100'
|
||||
: 'h-[3px] bg-gray-400 dark:bg-gray-500',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side="left" sideOffset={12} className="z-[999] max-w-[280px] px-3 py-2">
|
||||
<p className="line-clamp-3 text-xs text-text-secondary">{entry.preview}</p>
|
||||
<p className="line-clamp-3 text-xs text-text-secondary">{hoverText}</p>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCard>
|
||||
|
|
@ -238,53 +257,69 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
refreshEntries();
|
||||
}, [messagesById, refreshEntries]);
|
||||
|
||||
const scrollToStart = useCallback((id: string) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const container = el.closest<HTMLElement>('.scrollbar-gutter-stable');
|
||||
if (!container) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
return;
|
||||
}
|
||||
const token = ++scrollTokenRef.current;
|
||||
const scrollMargin = scrollMarginRef.current || readScrollMargin(el);
|
||||
const startScroll = container.scrollTop;
|
||||
const start = performance.now();
|
||||
const resolveEntryEl = useCallback(
|
||||
(id: string): HTMLElement | null => {
|
||||
if (id === MESSAGES_END_ID) {
|
||||
return scrollableRef.current?.querySelector<HTMLElement>('#' + MESSAGES_END_ID) ?? null;
|
||||
}
|
||||
return document.getElementById(id);
|
||||
},
|
||||
[scrollableRef],
|
||||
);
|
||||
|
||||
const step = (now: number) => {
|
||||
if (token !== scrollTokenRef.current) {
|
||||
const scrollToStart = useCallback(
|
||||
(id: string) => {
|
||||
const el = resolveEntryEl(id);
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const progress = Math.min(1, (now - start) / SCROLL_DURATION);
|
||||
const current = document.getElementById(id);
|
||||
if (!current) {
|
||||
const container = el.closest<HTMLElement>('.scrollbar-gutter-stable');
|
||||
if (!container) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
return;
|
||||
}
|
||||
const clamped = computeTargetScroll(container, current, scrollMargin);
|
||||
container.scrollTop = startScroll + (clamped - startScroll) * easeOutCubic(progress);
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(step);
|
||||
const token = ++scrollTokenRef.current;
|
||||
const scrollMargin = scrollMarginRef.current || readScrollMargin(el);
|
||||
const startScroll = container.scrollTop;
|
||||
const start = performance.now();
|
||||
|
||||
const step = (now: number) => {
|
||||
if (token !== scrollTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
const progress = Math.min(1, (now - start) / SCROLL_DURATION);
|
||||
const current = resolveEntryEl(id);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const clamped = computeTargetScroll(container, current, scrollMargin);
|
||||
container.scrollTop = startScroll + (clamped - startScroll) * easeOutCubic(progress);
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(step);
|
||||
},
|
||||
[resolveEntryEl],
|
||||
);
|
||||
|
||||
const scrollToImmediate = useCallback(
|
||||
(id: string) => {
|
||||
const el = resolveEntryEl(id);
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(step);
|
||||
}, []);
|
||||
|
||||
const scrollToImmediate = useCallback((id: string) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const container = el.closest<HTMLElement>('.scrollbar-gutter-stable');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
scrollTokenRef.current++;
|
||||
const scrollMargin = scrollMarginRef.current || readScrollMargin(el);
|
||||
container.scrollTop = computeTargetScroll(container, el, scrollMargin);
|
||||
}, []);
|
||||
const container = el.closest<HTMLElement>('.scrollbar-gutter-stable');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
scrollTokenRef.current++;
|
||||
const scrollMargin = scrollMarginRef.current || readScrollMargin(el);
|
||||
container.scrollTop = computeTargetScroll(container, el, scrollMargin);
|
||||
},
|
||||
[resolveEntryEl],
|
||||
);
|
||||
|
||||
const focusMessage = useCallback((id: string) => {
|
||||
const el = document.getElementById(id);
|
||||
|
|
@ -304,7 +339,9 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
return;
|
||||
}
|
||||
scrollToStart(id);
|
||||
focusMessage(id);
|
||||
if (id !== MESSAGES_END_ID) {
|
||||
focusMessage(id);
|
||||
}
|
||||
},
|
||||
[scrollToStart, focusMessage],
|
||||
);
|
||||
|
|
@ -479,7 +516,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
const offsetsBottom: number[] = new Array(entries.length);
|
||||
const recomputeOffsets = () => {
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const el = document.getElementById(entries[i].id);
|
||||
const el = resolveEntryEl(entries[i].id);
|
||||
offsetsTop[i] = el ? el.offsetTop : Number.POSITIVE_INFINITY;
|
||||
offsetsBottom[i] = el ? el.offsetTop + el.offsetHeight : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
|
@ -535,10 +572,11 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
}
|
||||
|
||||
const scrollTop = container.scrollTop;
|
||||
const containerMaxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||
let nextCanUp = false;
|
||||
let nextCanDown = false;
|
||||
for (let i = 0; i < offsetsTop.length; i++) {
|
||||
const snap = offsetsTop[i] - scrollMargin;
|
||||
const snap = Math.min(offsetsTop[i] - scrollMargin, containerMaxScrollTop);
|
||||
if (snap < scrollTop - JUMP_EPS) {
|
||||
nextCanUp = true;
|
||||
} else if (snap > scrollTop + JUMP_EPS) {
|
||||
|
|
@ -553,7 +591,6 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
if (!col) {
|
||||
return;
|
||||
}
|
||||
const containerMaxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||
if (containerMaxScrollTop > 0 && scrollTop >= containerMaxScrollTop - JUMP_EPS) {
|
||||
scheduleColumnBottomScroll();
|
||||
return;
|
||||
|
|
@ -618,7 +655,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
cancelColumnBottomScroll();
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [entries, scrollableRef]);
|
||||
}, [entries, scrollableRef, resolveEntryEl]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = scrollableRef.current;
|
||||
|
|
@ -691,7 +728,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
const elementByNewId = new Map<HTMLElement, string>();
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const id = entries[i].id;
|
||||
const el = document.getElementById(id);
|
||||
const el = resolveEntryEl(id);
|
||||
if (el) {
|
||||
elementByNewId.set(el, id);
|
||||
}
|
||||
|
|
@ -729,7 +766,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
setCurrentId(getCurrentVisibleId());
|
||||
setVisibleIds(new Set(visibleSet));
|
||||
}
|
||||
}, [entries, getCurrentVisibleId]);
|
||||
}, [entries, getCurrentVisibleId, resolveEntryEl]);
|
||||
|
||||
const jumpToPrevious = useCallback(() => {
|
||||
const container = scrollableRef.current;
|
||||
|
|
@ -742,7 +779,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
? scrollMarginRef.current
|
||||
: readScrollMargin(document.getElementById(entries[0].id));
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const el = document.getElementById(entries[i].id);
|
||||
const el = resolveEntryEl(entries[i].id);
|
||||
if (!el) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -752,7 +789,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
}
|
||||
}
|
||||
container.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}, [entries, scrollableRef, scrollToStart]);
|
||||
}, [entries, scrollableRef, scrollToStart, resolveEntryEl]);
|
||||
|
||||
const jumpToNext = useCallback(() => {
|
||||
const container = scrollableRef.current;
|
||||
|
|
@ -765,7 +802,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
? scrollMarginRef.current
|
||||
: readScrollMargin(document.getElementById(entries[0].id));
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const el = document.getElementById(entries[i].id);
|
||||
const el = resolveEntryEl(entries[i].id);
|
||||
if (!el) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -774,7 +811,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
return;
|
||||
}
|
||||
}
|
||||
}, [entries, scrollableRef, scrollToStart]);
|
||||
}, [entries, scrollableRef, scrollToStart, resolveEntryEl]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
|
|
@ -788,7 +825,9 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [focusNav]);
|
||||
|
||||
if (entries.length < 3) {
|
||||
const hasEnd = entries.length > 0 && entries[entries.length - 1].isEnd === true;
|
||||
const messageCount = hasEnd ? entries.length - 1 : entries.length;
|
||||
if (messageCount < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -821,19 +860,27 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
className="flex min-h-0 cursor-grab touch-none select-none flex-col items-center gap-1.5 overflow-y-auto active:cursor-grabbing [&::-webkit-scrollbar]:hidden"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<MessageIndicator
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isActive={visibleIds.has(entry.id)}
|
||||
isCurrent={currentId === entry.id}
|
||||
onSelect={handleSelect}
|
||||
label={localize(
|
||||
entry.isUser ? 'com_ui_message_nav_go_to_user' : 'com_ui_message_nav_go_to_assistant',
|
||||
{ 0: entry.preview.slice(0, 30) },
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{entries.map((entry) => {
|
||||
const label = entry.isEnd
|
||||
? localize('com_ui_scroll_to_bottom')
|
||||
: localize(
|
||||
entry.isUser
|
||||
? 'com_ui_message_nav_go_to_user'
|
||||
: 'com_ui_message_nav_go_to_assistant',
|
||||
{ 0: entry.preview.slice(0, 30) },
|
||||
);
|
||||
return (
|
||||
<MessageIndicator
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isActive={visibleIds.has(entry.id)}
|
||||
isCurrent={currentId === entry.id}
|
||||
onSelect={handleSelect}
|
||||
label={label}
|
||||
hoverText={entry.isEnd ? label : entry.preview}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1098,4 +1098,191 @@ describe('MessageNav', () => {
|
|||
expect(container.querySelector('[data-msg-id="d"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('end-of-conversation indicator', () => {
|
||||
function buildDomWithEnd(
|
||||
messages: TestMessage[],
|
||||
opts: { endOffsetTop?: number; scrollTop?: number } = {},
|
||||
) {
|
||||
const scrollable = document.createElement('div');
|
||||
scrollable.className = 'scrollbar-gutter-stable';
|
||||
Object.defineProperty(scrollable, 'clientHeight', { value: 600, configurable: true });
|
||||
Object.defineProperty(scrollable, 'scrollHeight', { value: 3000, configurable: true });
|
||||
Object.defineProperty(scrollable, 'scrollTop', {
|
||||
value: opts.scrollTop ?? 0,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'flex flex-col';
|
||||
scrollable.appendChild(content);
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const m = messages[i];
|
||||
const div = document.createElement('div');
|
||||
div.id = m.messageId;
|
||||
div.className = 'message-render';
|
||||
div.textContent = m.text ?? '';
|
||||
Object.defineProperty(div, 'offsetTop', { value: 100 + i * 200, configurable: true });
|
||||
Object.defineProperty(div, 'offsetHeight', { value: 150, configurable: true });
|
||||
content.appendChild(div);
|
||||
}
|
||||
|
||||
const end = document.createElement('div');
|
||||
end.id = 'messages-end';
|
||||
Object.defineProperty(end, 'offsetTop', {
|
||||
value: opts.endOffsetTop ?? 100 + messages.length * 200,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(end, 'offsetHeight', { value: 0, configurable: true });
|
||||
content.appendChild(end);
|
||||
|
||||
document.body.appendChild(scrollable);
|
||||
return { scrollable, content, end };
|
||||
}
|
||||
|
||||
function renderNavWithEnd(
|
||||
messages: TestMessage[],
|
||||
opts?: { endOffsetTop?: number; scrollTop?: number },
|
||||
) {
|
||||
mockUseGetMessagesByConvoId.mockReturnValue({ data: messages });
|
||||
const dom = buildDomWithEnd(messages, opts);
|
||||
const scrollableRef = { current: dom.scrollable } as RefObject<HTMLDivElement>;
|
||||
const result = render(<MessageNav scrollableRef={scrollableRef} />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(250);
|
||||
});
|
||||
return { ...result, ...dom, scrollableRef };
|
||||
}
|
||||
|
||||
const threeMessages = (): TestMessage[] => [
|
||||
buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }),
|
||||
buildMessage({ messageId: 'b', text: 'bravo' }),
|
||||
buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }),
|
||||
];
|
||||
|
||||
it('appends a terminus indicator as the last rib when #messages-end exists', () => {
|
||||
const { container } = renderNavWithEnd(threeMessages());
|
||||
const ribs = container.querySelectorAll('[data-msg-id]');
|
||||
expect(ribs).toHaveLength(4);
|
||||
expect(ribs[3].getAttribute('data-msg-id')).toBe('messages-end');
|
||||
expect(ribs[3].getAttribute('aria-label')).toBe('com_ui_scroll_to_bottom');
|
||||
});
|
||||
|
||||
it('omits the terminus indicator and the nav when there are fewer than 3 messages', () => {
|
||||
const messages = [
|
||||
buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }),
|
||||
buildMessage({ messageId: 'b', text: 'bravo' }),
|
||||
];
|
||||
const { container } = renderNavWithEnd(messages);
|
||||
expect(container.querySelector('nav')).toBeNull();
|
||||
});
|
||||
|
||||
it('scrolls toward the bottom without moving focus when the terminus is clicked', () => {
|
||||
const { container } = renderNavWithEnd(threeMessages());
|
||||
const rafSpy = jest.spyOn(window, 'requestAnimationFrame');
|
||||
const endRib = container.querySelector('[data-msg-id="messages-end"]') as HTMLButtonElement;
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(endRib);
|
||||
});
|
||||
|
||||
expect(rafSpy).toHaveBeenCalled();
|
||||
const end = document.getElementById('messages-end');
|
||||
expect(document.activeElement).not.toBe(end);
|
||||
expect(end?.hasAttribute('tabindex')).toBe(false);
|
||||
rafSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps the next chevron disabled at the bottom even when the terminus sits below max scroll', () => {
|
||||
const { container, scrollable } = renderNavWithEnd(threeMessages(), { endOffsetTop: 2900 });
|
||||
|
||||
Object.defineProperty(scrollable, 'scrollTop', {
|
||||
value: 2400,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.scroll(scrollable);
|
||||
jest.advanceTimersByTime(32);
|
||||
});
|
||||
|
||||
const prev = container.querySelector(
|
||||
'button[aria-label="com_ui_message_nav_previous"]',
|
||||
) as HTMLButtonElement;
|
||||
const next = container.querySelector(
|
||||
'button[aria-label="com_ui_message_nav_next"]',
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(next.disabled).toBe(true);
|
||||
expect(prev.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('scrubs to the terminus when dragging to the bottom of the column', () => {
|
||||
const messages = Array.from({ length: 5 }, (_, i) =>
|
||||
buildMessage({
|
||||
messageId: `m-${i}`,
|
||||
text: `message ${i}`,
|
||||
isCreatedByUser: i % 2 === 0,
|
||||
}),
|
||||
);
|
||||
const { container, scrollable } = renderNavWithEnd(messages);
|
||||
const column = container.querySelector('nav > div') as HTMLDivElement;
|
||||
column.getBoundingClientRect = () => ({ top: 0, bottom: 50, height: 50 }) as DOMRect;
|
||||
const qs = jest.spyOn(scrollable, 'querySelector');
|
||||
|
||||
act(() => {
|
||||
fireEvent.pointerDown(column, { pointerId: 1, button: 0, buttons: 1, clientY: 0 });
|
||||
fireEvent.pointerMove(document, { pointerId: 1, buttons: 1, clientY: 50 });
|
||||
});
|
||||
|
||||
expect(qs.mock.calls.some((c) => String(c[0]).includes('messages-end'))).toBe(true);
|
||||
qs.mockRestore();
|
||||
});
|
||||
|
||||
it('resolves the terminus within its own container across multiple mounted navs', () => {
|
||||
const messagesA = [
|
||||
buildMessage({ messageId: 'a1', text: 'one', isCreatedByUser: true }),
|
||||
buildMessage({ messageId: 'a2', text: 'two' }),
|
||||
buildMessage({ messageId: 'a3', text: 'three', isCreatedByUser: true }),
|
||||
];
|
||||
const messagesB = [
|
||||
buildMessage({ messageId: 'b1', text: 'alpha', isCreatedByUser: true }),
|
||||
buildMessage({ messageId: 'b2', text: 'beta' }),
|
||||
buildMessage({ messageId: 'b3', text: 'gamma', isCreatedByUser: true }),
|
||||
];
|
||||
|
||||
mockUseGetMessagesByConvoId.mockReturnValue({ data: messagesA });
|
||||
const domA = buildDomWithEnd(messagesA);
|
||||
render(
|
||||
<MessageNav scrollableRef={{ current: domA.scrollable } as RefObject<HTMLDivElement>} />,
|
||||
);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
mockUseGetMessagesByConvoId.mockReturnValue({ data: messagesB });
|
||||
const domB = buildDomWithEnd(messagesB);
|
||||
const { container: navB } = render(
|
||||
<MessageNav scrollableRef={{ current: domB.scrollable } as RefObject<HTMLDivElement>} />,
|
||||
);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
const qsA = jest.spyOn(domA.scrollable, 'querySelector');
|
||||
const qsB = jest.spyOn(domB.scrollable, 'querySelector');
|
||||
|
||||
const endRibB = navB.querySelector('[data-msg-id="messages-end"]') as HTMLButtonElement;
|
||||
act(() => {
|
||||
fireEvent.click(endRibB);
|
||||
});
|
||||
|
||||
expect(qsB.mock.calls.some((c) => String(c[0]).includes('messages-end'))).toBe(true);
|
||||
expect(qsA.mock.calls.some((c) => String(c[0]).includes('messages-end'))).toBe(false);
|
||||
qsA.mockRestore();
|
||||
qsB.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue