refactor: Consolidate Message Scrolling & other Logic to Custom Hooks 🔄 (#1257)

* refactor: remove unnecessary drilling/invoking of ScrollToBottom
- feat: useMessageScrolling: consolidates all scrolling logic to hook
- feat: useMessageHelpers: creates message utilities and consolidates logic from UI component

* fix: ensure automatic scrolling is triggered by messagesTree re-render and is throttled
This commit is contained in:
Danny Avila 2023-12-01 19:54:09 -05:00 committed by GitHub
parent ebd23f7295
commit 4674a54c70
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 208 additions and 169 deletions

View file

@ -112,7 +112,6 @@ export type TMessageProps = {
isSearchView?: boolean;
siblingIdx?: number;
siblingCount?: number;
scrollToBottom?: () => void;
setCurrentEditId?: React.Dispatch<React.SetStateAction<string | number | null>> | null;
setSiblingIdx?: ((value: number) => void | React.Dispatch<React.SetStateAction<number>>) | null;
};

View file

@ -68,7 +68,6 @@ const Image = ({
cx="60"
cy="60"
/>
{/* <circle className="origin-[50%_50%] -rotate-90 transition-[stroke-dashoffset]" stroke="currentColor" strokeWidth="10" strokeDashoffset="311.01767270538954" strokeDasharray="345.57519189487726 345.57519189487726" fill="transparent" r="55" cx="60" cy="60"/>*/}
<circle
className="origin-[50%_50%] -rotate-90 transition-[stroke-dashoffset]"
stroke="currentColor"

View file

@ -2,7 +2,7 @@ import { EModelEndpoint } from 'librechat-data-provider';
import type { ReactNode } from 'react';
import { MessagesSquared, GPTIcon } from '~/components/svg';
import { useChatContext } from '~/Providers';
import { Button } from '~/components';
import { Button } from '~/components/ui';
import { cn } from '~/utils/';
type TPopoverButton = {

View file

@ -1,121 +1,39 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useEffect } from 'react';
import copy from 'copy-to-clipboard';
import { useRecoilValue } from 'recoil';
import { Plugin } from '~/components/Messages/Content';
import MessageContent from './Content/MessageContent';
import { Icon } from '~/components/Endpoints';
import SiblingSwitch from './SiblingSwitch';
import type { TMessageProps } from '~/common';
import { useChatContext } from '~/Providers';
import SiblingSwitch from './SiblingSwitch';
import { useMessageHelpers } from '~/hooks';
// eslint-disable-next-line import/no-cycle
import MultiMessage from './MultiMessage';
import HoverButtons from './HoverButtons';
import SubRow from './SubRow';
import { cn } from '~/utils';
import store from '~/store';
export default function Message(props: TMessageProps) {
const autoScroll = useRecoilValue(store.autoScroll);
const {
message,
scrollToBottom,
currentEditId,
setCurrentEditId,
siblingIdx,
siblingCount,
setSiblingIdx,
} = props;
const { message, siblingIdx, siblingCount, setSiblingIdx, currentEditId, setCurrentEditId } =
props;
const {
ask,
regenerate,
abortScroll,
isSubmitting,
icon,
edit,
isLast,
enterEdit,
handleScroll,
conversation,
setAbortScroll,
handleContinue,
isSubmitting,
latestMessage,
setLatestMessage,
} = useChatContext();
const { conversationId } = conversation ?? {};
handleContinue,
copyToClipboard,
regenerateMessage,
} = useMessageHelpers(props);
const { text, children, messageId = null, isCreatedByUser, error, unfinished } = message ?? {};
const isLast = !children?.length;
const edit = messageId === currentEditId;
useEffect(() => {
if (isSubmitting && scrollToBottom && !abortScroll) {
scrollToBottom();
}
}, [isSubmitting, text, scrollToBottom, abortScroll]);
useEffect(() => {
if (scrollToBottom && autoScroll && conversationId !== 'new') {
scrollToBottom();
}
}, [autoScroll, conversationId, scrollToBottom]);
useEffect(() => {
if (!message) {
return;
} else if (isLast) {
setLatestMessage({ ...message });
}
}, [isLast, message, setLatestMessage]);
if (!message) {
return null;
}
const enterEdit = (cancel?: boolean) =>
setCurrentEditId && setCurrentEditId(cancel ? -1 : messageId);
const handleScroll = () => {
if (isSubmitting) {
setAbortScroll(true);
} else {
setAbortScroll(false);
}
};
// const commonClasses =
// 'w-full border-b text-gray-800 group border-black/10 dark:border-gray-900/50 dark:text-gray-100 dark:border-none';
// const uniqueClasses = isCreatedByUser
// ? 'bg-white dark:bg-gray-800 dark:text-gray-20'
// : 'bg-white dark:bg-gray-800 dark:text-gray-70';
// const messageProps = {
// className: cn(commonClasses, uniqueClasses),
// titleclass: '',
// };
const icon = Icon({
...conversation,
...message,
model: message?.model ?? conversation?.model,
size: 28.8,
});
const regenerateMessage = () => {
if (isSubmitting && isCreatedByUser) {
return;
}
regenerate(message);
};
const copyToClipboard = (setIsCopied: React.Dispatch<React.SetStateAction<boolean>>) => {
setIsCopied(true);
copy(text ?? '');
setTimeout(() => {
setIsCopied(false);
}, 3000);
};
return (
<>
<div
@ -198,7 +116,6 @@ export default function Message(props: TMessageProps) {
messageId={messageId}
conversation={conversation}
messagesTree={children ?? []}
scrollToBottom={scrollToBottom}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
/>

View file

@ -1,10 +1,9 @@
import { useLayoutEffect, useState, useRef, useCallback } from 'react';
import { useState } from 'react';
import type { ReactNode } from 'react';
import type { TMessage } from 'librechat-data-provider';
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
import { useScreenshot, useScrollToRef } from '~/hooks';
import { useScreenshot, useMessageScrolling } from '~/hooks';
import { CSSTransition } from 'react-transition-group';
import { useChatContext } from '~/Providers';
import MultiMessage from './MultiMessage';
export default function MessagesView({
@ -14,54 +13,19 @@ export default function MessagesView({
messagesTree?: TMessage[] | null;
Header?: ReactNode;
}) {
const timeoutIdRef = useRef<NodeJS.Timeout>();
const scrollableRef = useRef<HTMLDivElement | null>(null);
const messagesEndRef = useRef<HTMLDivElement | null>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
const { conversation, setAbortScroll } = useChatContext();
const { conversationId } = conversation ?? {};
const { screenshotTargetRef } = useScreenshot();
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
const checkIfAtBottom = useCallback(() => {
if (!scrollableRef.current) {
return;
}
const {
conversation,
scrollableRef,
messagesEndRef,
showScrollButton,
handleSmoothToRef,
debouncedHandleScroll,
} = useMessageScrolling(_messagesTree);
const { scrollTop, scrollHeight, clientHeight } = scrollableRef.current;
const diff = Math.abs(scrollHeight - scrollTop);
const percent = Math.abs(clientHeight - diff) / clientHeight;
const hasScrollbar = scrollHeight > clientHeight && percent >= 0.15;
setShowScrollButton(hasScrollbar);
}, [scrollableRef]);
useLayoutEffect(() => {
const scrollableElement = scrollableRef.current;
if (!scrollableElement) {
return;
}
const timeoutId = setTimeout(checkIfAtBottom, 650);
return () => {
clearTimeout(timeoutId);
};
}, [checkIfAtBottom]);
const debouncedHandleScroll = useCallback(() => {
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = setTimeout(checkIfAtBottom, 100);
}, [checkIfAtBottom]);
const scrollCallback = () => setShowScrollButton(false);
const { scrollToRef: scrollToBottom, handleSmoothToRef } = useScrollToRef({
targetRef: messagesEndRef,
callback: scrollCallback,
smoothCallback: () => {
scrollCallback();
setAbortScroll(false);
},
});
const { conversationId } = conversation ?? {};
return (
<div className="flex-1 overflow-hidden overflow-y-auto">
@ -86,9 +50,8 @@ export default function MessagesView({
<div ref={screenshotTargetRef}>
<MultiMessage
key={conversationId} // avoid internal state mixture
messageId={conversationId ?? null}
messagesTree={_messagesTree}
scrollToBottom={scrollToBottom}
messageId={conversationId ?? null}
setCurrentEditId={setCurrentEditId}
currentEditId={currentEditId ?? null}
/>

View file

@ -9,7 +9,6 @@ export default function MultiMessage({
// messageId is used recursively here
messageId,
messagesTree,
scrollToBottom,
currentEditId,
setCurrentEditId,
}: TMessageProps) {
@ -45,7 +44,6 @@ export default function MultiMessage({
<Message
key={message.messageId}
message={message}
scrollToBottom={scrollToBottom}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
siblingIdx={messagesTree.length - siblingIdx - 1}

View file

@ -1,6 +1,6 @@
import { EModelEndpoint } from 'librechat-data-provider';
import { Plugin, GPTIcon, AnthropicIcon, AzureMinimalIcon } from '~/components/svg';
import { useAuthContext } from '~/hooks';
import { useAuthContext } from '~/hooks/AuthContext';
import { IconProps } from '~/common';
import { cn } from '~/utils';

View file

@ -0,0 +1,2 @@
export { default as useMessageHelpers } from './useMessageHelpers';
export { default as useMessageScrolling } from './useMessageScrolling';

View file

@ -0,0 +1,83 @@
import { useEffect } from 'react';
import copy from 'copy-to-clipboard';
import type { TMessage } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
import Icon from '~/components/Endpoints/Icon';
import { useChatContext } from '~/Providers';
export default function useMessageHelpers(props: TMessageProps) {
const { message, currentEditId, setCurrentEditId } = props;
const {
ask,
regenerate,
isSubmitting,
conversation,
latestMessage,
setAbortScroll,
handleContinue,
setLatestMessage,
} = useChatContext();
const { text, children, messageId = null, isCreatedByUser } = message ?? {};
const edit = messageId === currentEditId;
const isLast = !children?.length;
useEffect(() => {
if (!message) {
return;
} else if (isLast) {
setLatestMessage({ ...message });
}
}, [isLast, message, setLatestMessage]);
const enterEdit = (cancel?: boolean) =>
setCurrentEditId && setCurrentEditId(cancel ? -1 : messageId);
const handleScroll = () => {
if (isSubmitting) {
setAbortScroll(true);
} else {
setAbortScroll(false);
}
};
const icon = Icon({
...conversation,
...(message as TMessage),
model: message?.model ?? conversation?.model,
size: 28.8,
});
const regenerateMessage = () => {
if ((isSubmitting && isCreatedByUser) || !message) {
return;
}
regenerate(message);
};
const copyToClipboard = (setIsCopied: React.Dispatch<React.SetStateAction<boolean>>) => {
setIsCopied(true);
copy(text ?? '');
setTimeout(() => {
setIsCopied(false);
}, 3000);
};
return {
ask,
icon,
edit,
isLast,
enterEdit,
conversation,
isSubmitting,
handleScroll,
latestMessage,
handleContinue,
copyToClipboard,
regenerateMessage,
};
}

View file

@ -0,0 +1,83 @@
import { useRecoilValue } from 'recoil';
import { useLayoutEffect, useState, useRef, useCallback, useEffect } from 'react';
import type { TMessage } from 'librechat-data-provider';
import useScrollToRef from '../useScrollToRef';
import { useChatContext } from '~/Providers';
import store from '~/store';
export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
const autoScroll = useRecoilValue(store.autoScroll);
const timeoutIdRef = useRef<NodeJS.Timeout>();
const scrollableRef = useRef<HTMLDivElement | null>(null);
const messagesEndRef = useRef<HTMLDivElement | null>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
const { conversation, setAbortScroll, isSubmitting, abortScroll } = useChatContext();
const { conversationId } = conversation ?? {};
const checkIfAtBottom = useCallback(() => {
if (!scrollableRef.current) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = scrollableRef.current;
const diff = Math.abs(scrollHeight - scrollTop);
const percent = Math.abs(clientHeight - diff) / clientHeight;
const hasScrollbar = scrollHeight > clientHeight && percent >= 0.15;
setShowScrollButton(hasScrollbar);
}, [scrollableRef]);
useLayoutEffect(() => {
const scrollableElement = scrollableRef.current;
if (!scrollableElement) {
return;
}
const timeoutId = setTimeout(checkIfAtBottom, 650);
return () => {
clearTimeout(timeoutId);
};
}, [checkIfAtBottom]);
const debouncedHandleScroll = useCallback(() => {
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = setTimeout(checkIfAtBottom, 100);
}, [checkIfAtBottom]);
const scrollCallback = () => setShowScrollButton(false);
const { scrollToRef: scrollToBottom, handleSmoothToRef } = useScrollToRef({
targetRef: messagesEndRef,
callback: scrollCallback,
smoothCallback: () => {
scrollCallback();
setAbortScroll(false);
},
});
useEffect(() => {
if (!messagesTree) {
return;
}
if (isSubmitting && scrollToBottom && !abortScroll) {
scrollToBottom();
}
}, [isSubmitting, messagesTree, scrollToBottom, abortScroll]);
useEffect(() => {
if (scrollToBottom && autoScroll && conversationId !== 'new') {
scrollToBottom();
}
}, [autoScroll, conversationId, scrollToBottom]);
return {
conversation,
scrollableRef,
messagesEndRef,
scrollToBottom,
showScrollButton,
handleSmoothToRef,
debouncedHandleScroll,
};
}

View file

@ -1,3 +1,5 @@
export * from './Messages';
export * from './AuthContext';
export * from './ThemeContext';
export * from './ScreenshotContext';

View file

@ -8,29 +8,22 @@ type TUseScrollToRef = {
};
export default function useScrollToRef({ targetRef, callback, smoothCallback }: TUseScrollToRef) {
const logAndScroll = (behavior: 'instant' | 'smooth', callbackFn: () => void) => {
// Debugging:
// console.log(`Scrolling with behavior: ${behavior}, Time: ${new Date().toISOString()}`);
targetRef.current?.scrollIntoView({ behavior });
callbackFn();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
const scrollToRef = useCallback(
throttle(
() => {
targetRef.current?.scrollIntoView({ behavior: 'instant' });
callback();
},
450,
{ leading: true },
),
throttle(() => logAndScroll('instant', callback), 450, { leading: true }),
[targetRef],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const scrollToRefSmooth = useCallback(
throttle(
() => {
targetRef.current?.scrollIntoView({ behavior: 'smooth' });
smoothCallback();
},
750,
{ leading: true },
),
throttle(() => logAndScroll('smooth', smoothCallback), 750, { leading: true }),
[targetRef],
);