{"version":3,"file":"chatMessageList.mjs","names":[],"sources":["../../../../package/common/chatMessageList/chatMessageList.tsx"],"sourcesContent":["import { useCallback, useImperativeHandle, useLayoutEffect, useRef, useState, type ReactElement } from 'react';\nimport cx from 'classnames';\nimport { faArrowDown } from '@fortawesome/free-solid-svg-icons';\n\nimport ActionIcon from '../actionIcon';\nimport { ButtonStyleType } from '../button/button.types';\nimport Loader from '../loader';\nimport { useComponentDefaults, useLabels } from '../configProvider';\nimport { useInfiniteScroll } from '../infiniteBlock/useInfiniteScroll';\nimport { ThemeColor } from '../themeProvider/color.types';\nimport type { ChatMessageListLabels, ChatMessageListProps } from './chatMessageList.types';\nimport styles from './chatMessageList.module.pcss';\n\nconst DEFAULT_LABELS: Required<ChatMessageListLabels> = {\n  messages: 'Messages',\n  scrollToBottom: 'Jump to latest',\n};\n\n/** How close to the bottom a user scrolling down gets before new messages are followed again. */\nconst STICK_DISTANCE = 32;\n\ninterface Anchor {\n  element: Element;\n  /** Distance from the viewport's top edge. */\n  top: number;\n}\n\n/**\n * The first message still showing at the top of the viewport, found by bisection because a long\n * conversation has thousands of children and this runs on every scroll event.\n */\nfunction findAnchor(viewport: HTMLElement, content: HTMLElement, skip: Element | null): Anchor | null {\n  const viewportTop = viewport.getBoundingClientRect().top;\n  const items = content.children;\n  let low = items[0] === skip ? 1 : 0;\n  let high = items.length - 1;\n  let found: Element | null = null;\n  while (low <= high) {\n    const middle = (low + high) >> 1;\n    if (items[middle].getBoundingClientRect().bottom > viewportTop) {\n      found = items[middle];\n      high = middle - 1;\n    } else {\n      low = middle + 1;\n    }\n  }\n  return found && { element: found, top: found.getBoundingClientRect().top - viewportTop };\n}\n\nfunction prefersReducedMotion(): boolean {\n  return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n}\n\nfunction ChatMessageList(props: ChatMessageListProps): ReactElement {\n  const {\n    children,\n    onLoadMore,\n    ref,\n    labels,\n    className,\n    style,\n    classNames,\n    styles: slotStyles,\n    ...rest\n  } = useComponentDefaults('ChatMessageList', props);\n  const text = useLabels('ChatMessageList', labels, DEFAULT_LABELS);\n  const viewportRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const isStuckRef = useRef(true);\n  const lastScrollTopRef = useRef(0);\n  const anchorRef = useRef<Anchor | null>(null);\n  const lastChildRef = useRef<Element | null>(null);\n  const [isAway, setIsAway] = useState(false);\n  const [hasNew, setHasNew] = useState(false);\n  const { isLoading, sentinelRef } = useInfiniteScroll(viewportRef, onLoadMore);\n\n  const handleScroll = () => {\n    const viewport = viewportRef.current;\n    const content = contentRef.current;\n    if (!viewport || !content) return;\n    const { scrollTop, scrollHeight, clientHeight } = viewport;\n    const distance = scrollHeight - scrollTop - clientHeight;\n    // Any move up lets go, however small: a slow drag would otherwise be pulled back down by each\n    // line of a streaming answer. The browser clamping scrollTop as content shrinks also moves it\n    // up, but lands on the bottom, which is why the distance has to be checked too.\n    if (scrollTop < lastScrollTopRef.current && distance > 1) isStuckRef.current = false;\n    else if (distance <= STICK_DISTANCE) isStuckRef.current = true;\n    lastScrollTopRef.current = scrollTop;\n    setIsAway(!isStuckRef.current);\n    if (isStuckRef.current) setHasNew(false);\n    anchorRef.current = findAnchor(viewport, content, sentinelRef.current);\n  };\n\n  // Scroll anchoring by hand: Safari has no `overflow-anchor`, and where it exists it does nothing\n  // at scrollTop 0, which is exactly where older messages get prepended. Runs in a ResizeObserver\n  // callback, after layout and before paint, so neither the jump nor its correction is ever seen.\n  useLayoutEffect(() => {\n    const viewport = viewportRef.current;\n    const content = contentRef.current;\n    if (!viewport || !content) return undefined;\n\n    const follow = () => {\n      // A new last child is a message arriving; one prepended at the top or growing as it streams\n      // leaves it the same.\n      const last = content.lastElementChild;\n      if (!isStuckRef.current && last !== lastChildRef.current) setHasNew(true);\n      lastChildRef.current = last;\n      if (isStuckRef.current) {\n        viewport.scrollTop = viewport.scrollHeight;\n      } else {\n        const anchor = anchorRef.current;\n        if (anchor?.element.isConnected) {\n          viewport.scrollTop +=\n            anchor.element.getBoundingClientRect().top - viewport.getBoundingClientRect().top - anchor.top;\n        }\n      }\n      // So the scroll event this causes is not taken for the user scrolling up.\n      lastScrollTopRef.current = viewport.scrollTop;\n      anchorRef.current = findAnchor(viewport, content, sentinelRef.current);\n    };\n\n    follow();\n    // The viewport too: a composer growing or a phone keyboard opening shrinks it from below.\n    const observer = new ResizeObserver(follow);\n    observer.observe(viewport);\n    observer.observe(content);\n    return () => observer.disconnect();\n  }, [sentinelRef]);\n\n  const scrollToBottom = useCallback(() => {\n    const viewport = viewportRef.current;\n    if (!viewport) return;\n    isStuckRef.current = true;\n    setIsAway(false);\n    setHasNew(false);\n    viewport.scrollTo({ top: viewport.scrollHeight, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });\n  }, []);\n\n  useImperativeHandle(ref, () => ({ scrollToBottom }), [scrollToBottom]);\n\n  return (\n    <div {...rest} className={cx(styles.chatMessageList, className)} style={style}>\n      <div\n        ref={viewportRef}\n        role='log'\n        aria-label={text.messages}\n        aria-busy={isLoading || undefined}\n        // A stop of its own, so the history can be scrolled from the keyboard between messages\n        // that hold nothing focusable.\n        tabIndex={0}\n        className={cx(styles.chatMessageListViewport, classNames?.viewport)}\n        style={slotStyles?.viewport}\n        onScroll={handleScroll}\n      >\n        <div\n          ref={contentRef}\n          className={cx(styles.chatMessageListContent, classNames?.content)}\n          style={slotStyles?.content}\n        >\n          {onLoadMore && (\n            <div ref={sentinelRef} className={styles.chatMessageListLoader}>\n              {isLoading && <Loader size='1.5em' />}\n            </div>\n          )}\n          {children}\n        </div>\n      </div>\n      {isAway && (\n        <ActionIcon\n          icon={faArrowDown}\n          styleType={hasNew ? ButtonStyleType.Filled : ButtonStyleType.Plain}\n          color={hasNew ? ThemeColor.Primary : ThemeColor.Neutral}\n          aria-label={text.scrollToBottom}\n          title={text.scrollToBottom}\n          className={cx(\n            styles.chatMessageListScrollButton,\n            !hasNew && styles.chatMessageListScrollButtonSurface,\n            classNames?.scrollButton,\n          )}\n          style={slotStyles?.scrollButton}\n          onClick={scrollToBottom}\n        />\n      )}\n    </div>\n  );\n}\n\nexport default ChatMessageList;\n"],"mappings":"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"}