diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index de5710d..142e19a 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -42,6 +42,7 @@ import { SupabaseConnection } from './SupabaseConnection'; import { ExpoQrModal } from '~/components/workbench/ExpoQrModal'; import { expoUrlAtom } from '~/lib/stores/qrCodeStore'; import { useStore } from '@nanostores/react'; +import { StickToBottom } from '~/lib/hooks'; const TEXTAREA_MIN_HEIGHT = 76; @@ -87,8 +88,6 @@ export const BaseChat = React.forwardRef( ( { textareaRef, - messageRef, - scrollRef, showChat = true, chatStarted = false, isStreaming = false, @@ -336,7 +335,7 @@ export const BaseChat = React.forwardRef( data-chat-visible={showChat} > {() => } -
+
{!chatStarted && (
@@ -348,24 +347,26 @@ export const BaseChat = React.forwardRef(

)} -
- - {() => { - return chatStarted ? ( - - ) : null; - }} - + + + {() => { + return chatStarted ? ( + + ) : null; + }} + + {deployAlert && ( ( /> )}
@@ -639,7 +640,7 @@ export const BaseChat = React.forwardRef(
-
+
{!chatStarted && (
diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index 4f97683..c5ff7db 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -8,7 +8,7 @@ import { useChat } from 'ai/react'; import { useAnimate } from 'framer-motion'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { cssTransition, toast, ToastContainer } from 'react-toastify'; -import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks'; +import { useMessageParser, usePromptEnhancer, useShortcuts } from '~/lib/hooks'; import { description, useChatHistory } from '~/lib/persistence'; import { chatStore } from '~/lib/stores/chat'; import { workbenchStore } from '~/lib/stores/workbench'; @@ -483,8 +483,6 @@ export const ChatImpl = memo( [], ); - const [messageRef, scrollRef] = useSnapScroll(); - useEffect(() => { const storedApiKeys = Cookies.get('apiKeys'); @@ -522,8 +520,6 @@ export const ChatImpl = memo( provider={provider} setProvider={handleProviderChange} providerList={activeProviders} - messageRef={messageRef} - scrollRef={scrollRef} handleInputChange={(e) => { onTextareaChange(e); debouncedCachePrompt(e); diff --git a/app/components/workbench/ExpoQrModal.tsx b/app/components/workbench/ExpoQrModal.tsx index bf7c403..636034d 100644 --- a/app/components/workbench/ExpoQrModal.tsx +++ b/app/components/workbench/ExpoQrModal.tsx @@ -20,8 +20,8 @@ export const ExpoQrModal: React.FC = ({ open, onClose }) => { onClose={onClose} >
-
- +
+ Preview on your own mobile device diff --git a/app/lib/hooks/StickToBottom.tsx b/app/lib/hooks/StickToBottom.tsx new file mode 100644 index 0000000..c07b739 --- /dev/null +++ b/app/lib/hooks/StickToBottom.tsx @@ -0,0 +1,153 @@ +/* + *!--------------------------------------------------------------------------------------------- + * Copyright (c) StackBlitz. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *-------------------------------------------------------------------------------------------- + */ + +import * as React from 'react'; +import { + type ReactNode, + createContext, + useContext, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, +} from 'react'; +import { + type GetTargetScrollTop, + type ScrollToBottom, + type StickToBottomOptions, + type StickToBottomState, + type StopScroll, + useStickToBottom, +} from './useStickToBottom'; + +export interface StickToBottomContext { + contentRef: React.MutableRefObject & React.RefCallback; + scrollRef: React.MutableRefObject & React.RefCallback; + scrollToBottom: ScrollToBottom; + stopScroll: StopScroll; + isAtBottom: boolean; + escapedFromLock: boolean; + get targetScrollTop(): GetTargetScrollTop | null; + set targetScrollTop(targetScrollTop: GetTargetScrollTop | null); + state: StickToBottomState; +} + +const StickToBottomContext = createContext(null); + +export interface StickToBottomProps + extends Omit, 'children'>, + StickToBottomOptions { + contextRef?: React.Ref; + instance?: ReturnType; + children: ((context: StickToBottomContext) => ReactNode) | ReactNode; +} + +const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; + +export function StickToBottom({ + instance, + children, + resize, + initial, + mass, + damping, + stiffness, + targetScrollTop: currentTargetScrollTop, + contextRef, + ...props +}: StickToBottomProps) { + const customTargetScrollTop = useRef(null); + + const targetScrollTop = React.useCallback( + (target, elements) => { + const get = context?.targetScrollTop ?? currentTargetScrollTop; + return get?.(target, elements) ?? target; + }, + [currentTargetScrollTop], + ); + + const defaultInstance = useStickToBottom({ + mass, + damping, + stiffness, + resize, + initial, + targetScrollTop, + }); + + const { scrollRef, contentRef, scrollToBottom, stopScroll, isAtBottom, escapedFromLock, state } = + instance ?? defaultInstance; + + const context = useMemo( + () => ({ + scrollToBottom, + stopScroll, + scrollRef, + isAtBottom, + escapedFromLock, + contentRef, + state, + get targetScrollTop() { + return customTargetScrollTop.current; + }, + set targetScrollTop(targetScrollTop: GetTargetScrollTop | null) { + customTargetScrollTop.current = targetScrollTop; + }, + }), + [scrollToBottom, isAtBottom, contentRef, scrollRef, stopScroll, escapedFromLock, state], + ); + + useImperativeHandle(contextRef, () => context, [context]); + + useIsomorphicLayoutEffect(() => { + if (!scrollRef.current) { + return; + } + + if (getComputedStyle(scrollRef.current).overflow === 'visible') { + scrollRef.current.style.overflow = 'auto'; + } + }, []); + + return ( + +
{typeof children === 'function' ? children(context) : children}
+
+ ); +} + +export interface StickToBottomContentProps extends Omit, 'children'> { + children: ((context: StickToBottomContext) => ReactNode) | ReactNode; +} + +function Content({ children, ...props }: StickToBottomContentProps) { + const context = useStickToBottomContext(); + + return ( +
+
+ {typeof children === 'function' ? children(context) : children} +
+
+ ); +} + +StickToBottom.Content = Content; + +/** + * Use this hook inside a component to gain access to whether the component is at the bottom of the scrollable area. + */ +export function useStickToBottomContext() { + const context = useContext(StickToBottomContext); + + if (!context) { + throw new Error('use-stick-to-bottom component context must be used within a StickToBottom component'); + } + + return context; +} diff --git a/app/lib/hooks/index.ts b/app/lib/hooks/index.ts index 20f5649..e2f6133 100644 --- a/app/lib/hooks/index.ts +++ b/app/lib/hooks/index.ts @@ -1,7 +1,7 @@ export * from './useMessageParser'; export * from './usePromptEnhancer'; export * from './useShortcuts'; -export * from './useSnapScroll'; +export * from './StickToBottom'; export * from './useEditChatDescription'; export { default } from './useViewport'; export { useUpdateCheck } from './useUpdateCheck'; diff --git a/app/lib/hooks/useSnapScroll.ts b/app/lib/hooks/useSnapScroll.ts deleted file mode 100644 index 742e836..0000000 --- a/app/lib/hooks/useSnapScroll.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { useRef, useCallback } from 'react'; - -interface ScrollOptions { - duration?: number; - easing?: 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'cubic-bezier'; - cubicBezier?: [number, number, number, number]; - bottomThreshold?: number; -} - -export function useSnapScroll(options: ScrollOptions = {}) { - const { - duration = 800, - easing = 'ease-in-out', - cubicBezier = [0.42, 0, 0.58, 1], - bottomThreshold = 50, // pixels from bottom to consider "scrolled to bottom" - } = options; - - const autoScrollRef = useRef(true); - const scrollNodeRef = useRef(); - const onScrollRef = useRef<() => void>(); - const observerRef = useRef(); - const animationFrameRef = useRef(); - const lastScrollTopRef = useRef(0); - - const smoothScroll = useCallback( - (element: HTMLDivElement, targetPosition: number, duration: number, easingFunction: string) => { - const startPosition = element.scrollTop; - const distance = targetPosition - startPosition; - const startTime = performance.now(); - - const bezierPoints = easingFunction === 'cubic-bezier' ? cubicBezier : [0.42, 0, 0.58, 1]; - - const cubicBezierFunction = (t: number): number => { - const [, y1, , y2] = bezierPoints; - - /* - * const cx = 3 * x1; - * const bx = 3 * (x2 - x1) - cx; - * const ax = 1 - cx - bx; - */ - - const cy = 3 * y1; - const by = 3 * (y2 - y1) - cy; - const ay = 1 - cy - by; - - // const sampleCurveX = (t: number) => ((ax * t + bx) * t + cx) * t; - const sampleCurveY = (t: number) => ((ay * t + by) * t + cy) * t; - - return sampleCurveY(t); - }; - - const animation = (currentTime: number) => { - const elapsedTime = currentTime - startTime; - const progress = Math.min(elapsedTime / duration, 1); - - const easedProgress = cubicBezierFunction(progress); - const newPosition = startPosition + distance * easedProgress; - - // Only scroll if auto-scroll is still enabled - if (autoScrollRef.current) { - element.scrollTop = newPosition; - } - - if (progress < 1 && autoScrollRef.current) { - animationFrameRef.current = requestAnimationFrame(animation); - } - }; - - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - } - - animationFrameRef.current = requestAnimationFrame(animation); - }, - [cubicBezier], - ); - - const isScrolledToBottom = useCallback( - (element: HTMLDivElement): boolean => { - const { scrollTop, scrollHeight, clientHeight } = element; - return scrollHeight - scrollTop - clientHeight <= bottomThreshold; - }, - [bottomThreshold], - ); - - const messageRef = useCallback( - (node: HTMLDivElement | null) => { - if (node) { - const observer = new ResizeObserver(() => { - if (autoScrollRef.current && scrollNodeRef.current) { - const { scrollHeight, clientHeight } = scrollNodeRef.current; - const scrollTarget = scrollHeight - clientHeight; - - smoothScroll(scrollNodeRef.current, scrollTarget, duration, easing); - } - }); - - observer.observe(node); - observerRef.current = observer; - } else { - observerRef.current?.disconnect(); - observerRef.current = undefined; - - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = undefined; - } - } - }, - [duration, easing, smoothScroll], - ); - - const scrollRef = useCallback( - (node: HTMLDivElement | null) => { - if (node) { - onScrollRef.current = () => { - const { scrollTop } = node; - - // Detect scroll direction - const isScrollingUp = scrollTop < lastScrollTopRef.current; - - // Update auto-scroll based on scroll direction and position - if (isScrollingUp) { - // Disable auto-scroll when scrolling up - autoScrollRef.current = false; - } else if (isScrolledToBottom(node)) { - // Re-enable auto-scroll when manually scrolled to bottom - autoScrollRef.current = true; - } - - // Store current scroll position for next comparison - lastScrollTopRef.current = scrollTop; - }; - - node.addEventListener('scroll', onScrollRef.current); - scrollNodeRef.current = node; - } else { - if (onScrollRef.current && scrollNodeRef.current) { - scrollNodeRef.current.removeEventListener('scroll', onScrollRef.current); - } - - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = undefined; - } - - scrollNodeRef.current = undefined; - onScrollRef.current = undefined; - } - }, - [isScrolledToBottom], - ); - - return [messageRef, scrollRef] as const; -} diff --git a/app/lib/hooks/useStickToBottom.tsx b/app/lib/hooks/useStickToBottom.tsx new file mode 100644 index 0000000..a36a29e --- /dev/null +++ b/app/lib/hooks/useStickToBottom.tsx @@ -0,0 +1,613 @@ +/* + *!--------------------------------------------------------------------------------------------- + * Copyright (c) StackBlitz. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *-------------------------------------------------------------------------------------------- + */ + +import { + type DependencyList, + type MutableRefObject, + type RefCallback, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; + +export interface StickToBottomState { + scrollTop: number; + lastScrollTop?: number; + ignoreScrollToTop?: number; + targetScrollTop: number; + calculatedTargetScrollTop: number; + scrollDifference: number; + resizeDifference: number; + + animation?: { + behavior: 'instant' | Required; + ignoreEscapes: boolean; + promise: Promise; + }; + lastTick?: number; + velocity: number; + accumulated: number; + + escapedFromLock: boolean; + isAtBottom: boolean; + isNearBottom: boolean; + + resizeObserver?: ResizeObserver; +} + +const DEFAULT_SPRING_ANIMATION = { + /** + * A value from 0 to 1, on how much to damp the animation. + * 0 means no damping, 1 means full damping. + * + * @default 0.7 + */ + damping: 0.7, + + /** + * The stiffness of how fast/slow the animation gets up to speed. + * + * @default 0.05 + */ + stiffness: 0.05, + + /** + * The inertial mass associated with the animation. + * Higher numbers make the animation slower. + * + * @default 1.25 + */ + mass: 1.25, +}; + +export interface SpringAnimation extends Partial {} + +export type Animation = ScrollBehavior | SpringAnimation; + +export interface ScrollElements { + scrollElement: HTMLElement; + contentElement: HTMLElement; +} + +export type GetTargetScrollTop = (targetScrollTop: number, context: ScrollElements) => number; + +export interface StickToBottomOptions extends SpringAnimation { + resize?: Animation; + initial?: Animation | boolean; + targetScrollTop?: GetTargetScrollTop; +} + +export type ScrollToBottomOptions = + | ScrollBehavior + | { + animation?: Animation; + + /** + * Whether to wait for any existing scrolls to finish before + * performing this one. Or if a millisecond is passed, + * it will wait for that duration before performing the scroll. + * + * @default false + */ + wait?: boolean | number; + + /** + * Whether to prevent the user from escaping the scroll, + * by scrolling up with their mouse. + */ + ignoreEscapes?: boolean; + + /** + * Only scroll to the bottom if we're already at the bottom. + * + * @default false + */ + preserveScrollPosition?: boolean; + + /** + * The extra duration in ms that this scroll event should persist for. + * (in addition to the time that it takes to get to the bottom) + * + * Not to be confused with the duration of the animation - + * for that you should adjust the animation option. + * + * @default 0 + */ + duration?: number | Promise; + }; + +export type ScrollToBottom = (scrollOptions?: ScrollToBottomOptions) => Promise | boolean; +export type StopScroll = () => void; + +const STICK_TO_BOTTOM_OFFSET_PX = 70; +const SIXTY_FPS_INTERVAL_MS = 1000 / 60; +const RETAIN_ANIMATION_DURATION_MS = 350; + +let mouseDown = false; + +globalThis.document?.addEventListener('mousedown', () => { + mouseDown = true; +}); + +globalThis.document?.addEventListener('mouseup', () => { + mouseDown = false; +}); + +globalThis.document?.addEventListener('click', () => { + mouseDown = false; +}); + +export const useStickToBottom = (options: StickToBottomOptions = {}) => { + const [escapedFromLock, updateEscapedFromLock] = useState(false); + const [isAtBottom, updateIsAtBottom] = useState(options.initial !== false); + const [isNearBottom, setIsNearBottom] = useState(false); + + const optionsRef = useRef(null!); + optionsRef.current = options; + + const isSelecting = useCallback(() => { + if (!mouseDown) { + return false; + } + + const selection = window.getSelection(); + + if (!selection || !selection.rangeCount) { + return false; + } + + const range = selection.getRangeAt(0); + + return ( + range.commonAncestorContainer.contains(scrollRef.current) || + scrollRef.current?.contains(range.commonAncestorContainer) + ); + }, []); + + const setIsAtBottom = useCallback((isAtBottom: boolean) => { + state.isAtBottom = isAtBottom; + updateIsAtBottom(isAtBottom); + }, []); + + const setEscapedFromLock = useCallback((escapedFromLock: boolean) => { + state.escapedFromLock = escapedFromLock; + updateEscapedFromLock(escapedFromLock); + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: not needed + const state = useMemo(() => { + let lastCalculation: { targetScrollTop: number; calculatedScrollTop: number } | undefined; + + return { + escapedFromLock, + isAtBottom, + resizeDifference: 0, + accumulated: 0, + velocity: 0, + listeners: new Set(), + + get scrollTop() { + return scrollRef.current?.scrollTop ?? 0; + }, + set scrollTop(scrollTop: number) { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollTop; + state.ignoreScrollToTop = scrollRef.current.scrollTop; + } + }, + + get targetScrollTop() { + if (!scrollRef.current || !contentRef.current) { + return 0; + } + + return scrollRef.current.scrollHeight - 1 - scrollRef.current.clientHeight; + }, + get calculatedTargetScrollTop() { + if (!scrollRef.current || !contentRef.current) { + return 0; + } + + const { targetScrollTop } = this; + + if (!options.targetScrollTop) { + return targetScrollTop; + } + + if (lastCalculation?.targetScrollTop === targetScrollTop) { + return lastCalculation.calculatedScrollTop; + } + + const calculatedScrollTop = Math.max( + Math.min( + options.targetScrollTop(targetScrollTop, { + scrollElement: scrollRef.current, + contentElement: contentRef.current, + }), + targetScrollTop, + ), + 0, + ); + + lastCalculation = { targetScrollTop, calculatedScrollTop }; + + requestAnimationFrame(() => { + lastCalculation = undefined; + }); + + return calculatedScrollTop; + }, + + get scrollDifference() { + return this.calculatedTargetScrollTop - this.scrollTop; + }, + + get isNearBottom() { + return this.scrollDifference <= STICK_TO_BOTTOM_OFFSET_PX; + }, + }; + }, []); + + const scrollToBottom = useCallback( + (scrollOptions = {}) => { + if (typeof scrollOptions === 'string') { + scrollOptions = { animation: scrollOptions }; + } + + if (!scrollOptions.preserveScrollPosition) { + setIsAtBottom(true); + } + + const waitElapsed = Date.now() + (Number(scrollOptions.wait) || 0); + const behavior = mergeAnimations(optionsRef.current, scrollOptions.animation); + const { ignoreEscapes = false } = scrollOptions; + + let durationElapsed: number; + let startTarget = state.calculatedTargetScrollTop; + + if (scrollOptions.duration instanceof Promise) { + scrollOptions.duration.finally(() => { + durationElapsed = Date.now(); + }); + } else { + durationElapsed = waitElapsed + (scrollOptions.duration ?? 0); + } + + const next = async (): Promise => { + const promise = new Promise(requestAnimationFrame).then(() => { + if (!state.isAtBottom) { + state.animation = undefined; + + return false; + } + + const { scrollTop } = state; + const tick = performance.now(); + const tickDelta = (tick - (state.lastTick ?? tick)) / SIXTY_FPS_INTERVAL_MS; + state.animation ||= { behavior, promise, ignoreEscapes }; + + if (state.animation.behavior === behavior) { + state.lastTick = tick; + } + + if (isSelecting()) { + return next(); + } + + if (waitElapsed > Date.now()) { + return next(); + } + + if (scrollTop < Math.min(startTarget, state.calculatedTargetScrollTop)) { + if (state.animation?.behavior === behavior) { + if (behavior === 'instant') { + state.scrollTop = state.calculatedTargetScrollTop; + return next(); + } + + state.velocity = + (behavior.damping * state.velocity + behavior.stiffness * state.scrollDifference) / behavior.mass; + state.accumulated += state.velocity * tickDelta; + state.scrollTop += state.accumulated; + + if (state.scrollTop !== scrollTop) { + state.accumulated = 0; + } + } + + return next(); + } + + if (durationElapsed > Date.now()) { + startTarget = state.calculatedTargetScrollTop; + + return next(); + } + + state.animation = undefined; + + /** + * If we're still below the target, then queue + * up another scroll to the bottom with the last + * requested animatino. + */ + if (state.scrollTop < state.calculatedTargetScrollTop) { + return scrollToBottom({ + animation: mergeAnimations(optionsRef.current, optionsRef.current.resize), + ignoreEscapes, + duration: Math.max(0, durationElapsed - Date.now()) || undefined, + }); + } + + return state.isAtBottom; + }); + + return promise.then((isAtBottom) => { + requestAnimationFrame(() => { + if (!state.animation) { + state.lastTick = undefined; + state.velocity = 0; + } + }); + + return isAtBottom; + }); + }; + + if (scrollOptions.wait !== true) { + state.animation = undefined; + } + + if (state.animation?.behavior === behavior) { + return state.animation.promise; + } + + return next(); + }, + [setIsAtBottom, isSelecting, state], + ); + + const stopScroll = useCallback(() => { + setEscapedFromLock(true); + setIsAtBottom(false); + }, [setEscapedFromLock, setIsAtBottom]); + + const handleScroll = useCallback( + ({ target }: Event) => { + if (target !== scrollRef.current) { + return; + } + + const { scrollTop, ignoreScrollToTop } = state; + let { lastScrollTop = scrollTop } = state; + + state.lastScrollTop = scrollTop; + state.ignoreScrollToTop = undefined; + + if (ignoreScrollToTop && ignoreScrollToTop > scrollTop) { + /** + * When the user scrolls up while the animation plays, the `scrollTop` may + * not come in separate events; if this happens, to make sure `isScrollingUp` + * is correct, set the lastScrollTop to the ignored event. + */ + lastScrollTop = ignoreScrollToTop; + } + + setIsNearBottom(state.isNearBottom); + + /** + * Scroll events may come before a ResizeObserver event, + * so in order to ignore resize events correctly we use a + * timeout. + * + * @see https://github.com/WICG/resize-observer/issues/25#issuecomment-248757228 + */ + setTimeout(() => { + /** + * When theres a resize difference ignore the resize event. + */ + if (state.resizeDifference || scrollTop === ignoreScrollToTop) { + return; + } + + if (isSelecting()) { + setEscapedFromLock(true); + setIsAtBottom(false); + + return; + } + + const isScrollingDown = scrollTop > lastScrollTop; + const isScrollingUp = scrollTop < lastScrollTop; + + if (state.animation?.ignoreEscapes) { + state.scrollTop = lastScrollTop; + return; + } + + if (isScrollingUp) { + setEscapedFromLock(true); + setIsAtBottom(false); + } + + if (isScrollingDown) { + setEscapedFromLock(false); + } + + if (!state.escapedFromLock && state.isNearBottom) { + setIsAtBottom(true); + } + }, 1); + }, + [setEscapedFromLock, setIsAtBottom, isSelecting, state], + ); + + const handleWheel = useCallback( + ({ target, deltaY }: WheelEvent) => { + let element = target as HTMLElement; + + while (!['scroll', 'auto'].includes(getComputedStyle(element).overflow)) { + if (!element.parentElement) { + return; + } + + element = element.parentElement; + } + + /** + * The browser may cancel the scrolling from the mouse wheel + * if we update it from the animation in meantime. + * To prevent this, always escape when the wheel is scrolled up. + */ + if ( + element === scrollRef.current && + deltaY < 0 && + scrollRef.current.scrollHeight > scrollRef.current.clientHeight && + !state.animation?.ignoreEscapes + ) { + setEscapedFromLock(true); + setIsAtBottom(false); + } + }, + [setEscapedFromLock, setIsAtBottom, state], + ); + + const scrollRef = useRefCallback((scroll) => { + scrollRef.current?.removeEventListener('scroll', handleScroll); + scrollRef.current?.removeEventListener('wheel', handleWheel); + scroll?.addEventListener('scroll', handleScroll, { passive: true }); + scroll?.addEventListener('wheel', handleWheel, { passive: true }); + }, []); + + const contentRef = useRefCallback((content) => { + state.resizeObserver?.disconnect(); + + if (!content) { + return; + } + + let previousHeight: number | undefined; + + state.resizeObserver = new ResizeObserver(([entry]) => { + const { height } = entry.contentRect; + const difference = height - (previousHeight ?? height); + + state.resizeDifference = difference; + + /** + * Sometimes the browser can overscroll past the target, + * so check for this and adjust appropriately. + */ + if (state.scrollTop > state.targetScrollTop) { + state.scrollTop = state.targetScrollTop; + } + + setIsNearBottom(state.isNearBottom); + + if (difference >= 0) { + /** + * If it's a positive resize, scroll to the bottom when + * we're already at the bottom. + */ + const animation = mergeAnimations( + optionsRef.current, + previousHeight ? optionsRef.current.resize : optionsRef.current.initial, + ); + + scrollToBottom({ + animation, + wait: true, + preserveScrollPosition: true, + duration: animation === 'instant' ? undefined : RETAIN_ANIMATION_DURATION_MS, + }); + } else { + /** + * Else if it's a negative resize, check if we're near the bottom + * if we are want to un-escape from the lock, because the resize + * could have caused the container to be at the bottom. + */ + if (state.isNearBottom) { + setEscapedFromLock(false); + setIsAtBottom(true); + } + } + + previousHeight = height; + + /** + * Reset the resize difference after the scroll event + * has fired. Requires a rAF to wait for the scroll event, + * and a setTimeout to wait for the other timeout we have in + * resizeObserver in case the scroll event happens after the + * resize event. + */ + requestAnimationFrame(() => { + setTimeout(() => { + if (state.resizeDifference === difference) { + state.resizeDifference = 0; + } + }, 1); + }); + }); + + state.resizeObserver?.observe(content); + }, []); + + return { + contentRef, + scrollRef, + scrollToBottom, + stopScroll, + isAtBottom: isAtBottom || isNearBottom, + isNearBottom, + escapedFromLock, + state, + }; +}; + +function useRefCallback any>(callback: T, deps: DependencyList) { + // biome-ignore lint/correctness/useExhaustiveDependencies: not needed + const result = useCallback((ref: HTMLElement | null) => { + result.current = ref; + return callback(ref); + }, deps) as any as MutableRefObject & RefCallback; + + return result; +} + +const animationCache = new Map>>(); + +function mergeAnimations(...animations: (Animation | boolean | undefined)[]) { + const result = { ...DEFAULT_SPRING_ANIMATION }; + let instant = false; + + for (const animation of animations) { + if (animation === 'instant') { + instant = true; + continue; + } + + if (typeof animation !== 'object') { + continue; + } + + instant = false; + + result.damping = animation.damping ?? result.damping; + result.stiffness = animation.stiffness ?? result.stiffness; + result.mass = animation.mass ?? result.mass; + } + + const key = JSON.stringify(result); + + if (!animationCache.has(key)) { + animationCache.set(key, Object.freeze(result)); + } + + return instant ? 'instant' : animationCache.get(key)!; +}