{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bubble",
  "title": "Bubble",
  "description": "MateChat Bubble",
  "dependencies": [
    "react-markdown",
    "remark-gfm",
    "remark-math",
    "react-syntax-highlighter"
  ],
  "files": [
    {
      "path": "src/bubble.tsx",
      "content": "import type { UIMessage } from \"ai\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport clsx from \"clsx\";\nimport type React from \"react\";\nimport { memo, useCallback, useEffect, useRef } from \"react\";\nimport Markdown from \"react-markdown\";\nimport remarkGfm from \"remark-gfm\";\nimport remarkMath from \"remark-math\";\nimport { twMerge } from \"tailwind-merge\";\nimport { BlockQuote, CodeBlock, Heading, Link } from \"@/markdown\";\n\nconst bubbleVariants = cva(\n  \"flex flex-col gap-1 justify-center rounded-lg dark:text-gray-200 text-gray-800 max-w-full whitespace-pre-wrap wrap-break-word\",\n  {\n    variants: {\n      size: {\n        default: \"px-4 py-2\",\n        lg: \"px-6 py-3 text-lg\",\n        md: \"px-4 py-2 text-base\",\n        sm: \"px-3 py-1 text-sm\",\n        xs: \"px-2 py-1 text-xs\",\n      },\n      align: {\n        left: \"self-start\",\n        center: \"self-center\",\n        right: \"self-end\",\n      },\n      background: {\n        transparent: \"bg-transparent\",\n        solid: \"bg-gray-100 dark:bg-gray-800\",\n      },\n    },\n    defaultVariants: {\n      size: \"default\",\n      align: \"left\",\n    },\n  },\n);\n\nexport interface BubbleProps\n  extends React.ComponentProps<\"div\">,\n    VariantProps<typeof bubbleVariants> {\n  text: string;\n  background?: \"transparent\" | \"solid\";\n  pending?: React.ReactNode;\n  isPending?: boolean;\n}\n\nexport function Bubble({\n  className,\n  text,\n  size,\n  align,\n  background = \"solid\",\n  pending,\n  isPending = false,\n  ...props\n}: BubbleProps) {\n  const defaultPending = (\n    <div className=\"flex items-center space-x-1 py-1\">\n      <div className=\"w-2 h-2 bg-gray-400 rounded-full animate-bounce\" />\n      <div\n        className=\"w-2 h-2 bg-gray-400 rounded-full animate-bounce\"\n        style={{ animationDelay: \"0.1s\" }}\n      />\n      <div\n        className=\"w-2 h-2 bg-gray-400 rounded-full animate-bounce\"\n        style={{ animationDelay: \"0.2s\" }}\n      />\n    </div>\n  );\n\n  return (\n    <div\n      data-slot=\"bubble\"\n      className={twMerge(\n        clsx(\n          bubbleVariants({\n            className,\n            size,\n            align,\n            background,\n          }),\n          pending && \"flex items-center\",\n        ),\n      )}\n      {...props}\n    >\n      {isPending ? (\n        pending || defaultPending\n      ) : (\n        <Markdown\n          remarkPlugins={[remarkGfm, remarkMath]}\n          components={{\n            a: Link,\n            code: CodeBlock,\n            blockquote: BlockQuote,\n            h1: (p) => <Heading {...p} level={1} />,\n            h2: (p) => <Heading {...p} level={2} />,\n            h3: (p) => <Heading {...p} level={3} />,\n            h4: (p) => <Heading {...p} level={4} />,\n            h5: (p) => <Heading {...p} level={5} />,\n            h6: (p) => <Heading {...p} level={6} />,\n          }}\n        >\n          {text}\n        </Markdown>\n      )}\n    </div>\n  );\n}\n\nexport interface AvatarProps extends React.ComponentProps<\"div\"> {\n  text?: string;\n  imageUrl?: string;\n}\n\nexport function Avatar({ className, text, imageUrl, ...props }: AvatarProps) {\n  return (\n    <div\n      data-slot=\"avatar\"\n      className={twMerge(\n        clsx(\n          \"flex items-center justify-center w-9 h-9 rounded-full bg-gray-300 dark:bg-gray-800 dark:text-gray-200 text-gray-800\",\n          className,\n        ),\n      )}\n      {...props}\n    >\n      {imageUrl ? (\n        <img\n          className=\"w-full h-full object-cover rounded-full\"\n          src={imageUrl}\n          alt={text}\n        />\n      ) : (\n        text\n      )}\n    </div>\n  );\n}\n\nfunction getTextFromParts(parts: UIMessage[\"parts\"]): string {\n  return parts\n    .filter(\n      (part): part is { type: \"text\"; text: string } => part.type === \"text\",\n    )\n    .map((part) => part.text)\n    .join(\"\");\n}\n\nfunction getAlignFromRole(role: UIMessage[\"role\"]): \"left\" | \"right\" {\n  return role === \"user\" ? \"right\" : \"left\";\n}\n\nfunction isAvatarProps(obj: unknown): obj is AvatarProps {\n  return (\n    typeof obj === \"object\" &&\n    obj !== null &&\n    (\"text\" in obj || \"imageUrl\" in obj)\n  );\n}\n\nfunction getAvatarFromMetadata(\n  metadata: UIMessage[\"metadata\"],\n): AvatarProps | string | undefined {\n  if (!metadata || typeof metadata !== \"object\") {\n    return undefined;\n  }\n  if (!(\"avatar\" in metadata)) {\n    return undefined;\n  }\n  const avatar = metadata.avatar;\n  if (typeof avatar === \"string\") {\n    return avatar;\n  }\n  if (isAvatarProps(avatar)) {\n    return avatar;\n  }\n  return undefined;\n}\n\nexport interface BubbleListProps extends React.ComponentProps<\"div\"> {\n  messages: UIMessage[];\n  background?: \"transparent\" | \"solid\" | \"left-solid\" | \"right-solid\";\n  isPending?: boolean;\n  assistant?: {\n    avatar?: AvatarProps;\n    align?: \"left\" | \"right\";\n  };\n  footer?: React.ReactNode;\n  pending?: React.ReactNode;\n  threshold?: number;\n}\n\nexport const BubbleList = memo(function BubbleList({\n  className,\n  background = \"right-solid\",\n  footer,\n  pending,\n  assistant,\n  isPending = true,\n  messages,\n  threshold = 8,\n  ...props\n}: BubbleListProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n\n  const pauseScroll = useRef<boolean>(false);\n  const contentRect = useRef<DOMRect>(new DOMRect());\n\n  const scrollContainer = useCallback((smooth?: boolean) => {\n    if (pauseScroll.current) return;\n\n    requestAnimationFrame(() => {\n      containerRef.current?.scrollTo({\n        top: containerRef.current?.scrollHeight,\n        behavior: smooth ? \"smooth\" : \"instant\",\n      });\n    });\n  }, []);\n\n  useEffect(() => {\n    if (!containerRef.current || !contentRef.current) return;\n\n    const observer = new ResizeObserver((entries) => {\n      for (const entry of entries) {\n        const { height, width } = entry.contentRect;\n        if (\n          Math.abs(contentRect.current.height - height) > threshold ||\n          Math.abs(contentRect.current.width - width) > threshold\n        ) {\n          contentRect.current = entry.contentRect;\n          scrollContainer();\n        }\n      }\n    });\n\n    observer.observe(containerRef.current);\n    observer.observe(contentRef.current);\n\n    return () => observer.disconnect();\n  }, [scrollContainer, threshold]);\n\n  const isScrollAtBottom = useCallback(() => {\n    const container = containerRef.current;\n    if (!container) return true;\n\n    return (\n      Math.abs(\n        container.scrollTop + container.clientHeight - container.scrollHeight,\n      ) < threshold\n    );\n  }, [threshold]);\n\n  const handleWheel = useCallback(() => {\n    const container = containerRef.current;\n    if (!container) return;\n\n    if (isScrollAtBottom()) {\n      pauseScroll.current = false;\n    } else {\n      pauseScroll.current = true;\n    }\n  }, [isScrollAtBottom]);\n\n  const handleTouchStart = useCallback(() => {\n    pauseScroll.current = true;\n  }, []);\n\n  const handleTouchEnd = useCallback(() => {\n    if (isScrollAtBottom()) {\n      pauseScroll.current = false;\n      scrollContainer(false);\n    } else {\n      pauseScroll.current = true;\n    }\n  }, [isScrollAtBottom, scrollContainer]);\n\n  const handleTouchMove = useCallback(() => {\n    pauseScroll.current = true;\n  }, []);\n\n  return (\n    <div\n      data-slot=\"bubble-list\"\n      className={twMerge(\n        clsx(\"flex flex-col overflow-y-auto flex-1 gap-4\", className),\n      )}\n      ref={containerRef}\n      onWheel={handleWheel}\n      onTouchStart={handleTouchStart}\n      onTouchEnd={handleTouchEnd}\n      onTouchMove={handleTouchMove}\n      {...props}\n    >\n      <div\n        data-slot=\"bubble-items\"\n        className=\"flex flex-col max-w-full flex-1 gap-4\"\n        ref={contentRef}\n      >\n        {messages.map((message) => {\n          const text = getTextFromParts(message.parts);\n          const align = getAlignFromRole(message.role);\n          const avatar = getAvatarFromMetadata(message.metadata);\n\n          return (\n            <div\n              key={message.id}\n              data-slot=\"bubble-item\"\n              className={twMerge(\n                clsx(\n                  \"flex items-start gap-2\",\n                  align === \"right\" && \"flex-row-reverse\",\n                ),\n              )}\n            >\n              {avatar && (\n                <Avatar\n                  className=\"shrink-0\"\n                  {...(typeof avatar === \"string\"\n                    ? { imageUrl: avatar }\n                    : avatar)}\n                />\n              )}\n              <Bubble\n                text={text}\n                align={align}\n                background={\n                  (background === \"left-solid\" && align === \"left\") ||\n                  (background === \"right-solid\" && align === \"right\") ||\n                  background === \"solid\"\n                    ? \"solid\"\n                    : \"transparent\"\n                }\n              />\n            </div>\n          );\n        })}\n        {isPending && (\n          <div\n            key=\"pending\"\n            data-slot=\"bubble-item\"\n            className={twMerge(\n              clsx(assistant?.align === \"right\" && \"flex-row-reverse\"),\n              \"flex items-start gap-2 w-full\",\n            )}\n          >\n            <Avatar className=\"shrink-0\" {...(assistant?.avatar || {})} />\n            <Bubble\n              isPending={isPending}\n              pending={pending}\n              text=\"\"\n              align={assistant?.align || \"left\"}\n              background={\n                (background === \"left-solid\" &&\n                  (assistant?.align || \"left\") === \"left\") ||\n                (background === \"right-solid\" &&\n                  (assistant?.align || \"left\") === \"right\") ||\n                background === \"solid\"\n                  ? \"solid\"\n                  : \"transparent\"\n              }\n            />\n          </div>\n        )}\n      </div>\n      {footer && (\n        <div\n          data-slot=\"bubble-footer\"\n          className=\"flex items-center justify-center mt-4\"\n        >\n          {footer}\n        </div>\n      )}\n    </div>\n  );\n});\n",
      "type": "registry:component"
    },
    {
      "path": "src/markdown.tsx",
      "content": "import clsx from \"clsx\";\nimport { useCallback, useState } from \"react\";\nimport SyntaxHighlighter from \"react-syntax-highlighter\";\nimport {\n  oneLight,\n  vscDarkPlus,\n} from \"react-syntax-highlighter/dist/esm/styles/prism\";\nimport { useTheme } from \"@/hooks/use-theme\";\n\nexport interface HeadingProps extends React.ComponentProps<\"h1\"> {\n  level: 1 | 2 | 3 | 4 | 5 | 6;\n}\n\nconst headingVariant = {\n  1: \"text-2xl font-bold my-3\",\n  2: \"text-xl font-bold my-2\",\n  3: \"text-lg font-bold my-1\",\n  4: \"text-md font-bold my-1\",\n  5: \"text-base font-bold\",\n  6: \"text-base font-bold\",\n};\n\nexport function Heading({ children, className, ...rest }: HeadingProps) {\n  const { level } = rest;\n  return (\n    <h1 {...rest} className={clsx(headingVariant[level], className)}>\n      {children}\n    </h1>\n  );\n}\n\nexport interface CodeBlockProps extends React.ComponentProps<\"code\"> {}\n\nexport function CodeBlock({\n  children,\n  className,\n  ref: _ref,\n  ...rest\n}: CodeBlockProps) {\n  const { isDark } = useTheme();\n  const match = /language-(\\w+)/.exec(className || \"\");\n\n  const [copied, setCopied] = useState<boolean>(false);\n  const handleCopy = useCallback(() => {\n    navigator.clipboard.writeText(String(children).replace(/\\n$/, \"\"));\n    setCopied(true);\n    setTimeout(() => setCopied(false), 2000);\n  }, [children]);\n\n  return match ? (\n    <div\n      className={clsx(\n        \"w-full overflow-x-auto rounded-lg\",\n        \"bg-gray-50 dark:bg-gray-800\",\n      )}\n    >\n      <div className=\"inline-flex w-full justify-between bg-gray-100 p-2\">\n        <div className=\"px-2 py-1 text-xs text-gray-900 dark:text-gray-400\">\n          {match[1]}\n        </div>\n        <button\n          type=\"button\"\n          className=\"px-2 py-1 text-xs text-gray-900 dark:text-gray-400 cursor-pointer\"\n          onClick={handleCopy}\n        >\n          {copied ? \"Copied\" : \"Copy\"}\n        </button>\n      </div>\n      <SyntaxHighlighter\n        {...rest}\n        PreTag=\"div\"\n        language={match[1]}\n        style={isDark ? vscDarkPlus : oneLight}\n        customStyle={{\n          background: \"transparent\",\n          margin: 0,\n          padding: \"1rem\",\n          borderRadius: \"0.5rem\",\n          overflowX: \"auto\",\n        }}\n        codeTagProps={{\n          style: {\n            fontFamily: \"monospace\",\n            fontSize: \"0.875rem\",\n          },\n        }}\n      >\n        {String(children).replace(/\\n$/, \"\")}\n      </SyntaxHighlighter>\n    </div>\n  ) : (\n    <code\n      {...rest}\n      className={clsx(\n        \"rounded-md px-1 py-0.5 text-[85%]\",\n        \"bg-gray-100 dark:bg-gray-800\",\n      )}\n    >\n      {children}\n    </code>\n  );\n}\n\nexport interface BlockQuoteProps extends React.ComponentProps<\"blockquote\"> {}\n\nexport function BlockQuote({ children, className, ...rest }: BlockQuoteProps) {\n  return (\n    <blockquote\n      {...rest}\n      className={clsx(\"border-l-4 border-gray-300 pl-4 italic\", className)}\n    >\n      {children}\n    </blockquote>\n  );\n}\n\nexport interface LinkProps extends React.ComponentProps<\"a\"> {}\n\nconst UNSAFE_HREF_PATTERN = /^(javascript|data|vbscript):/i;\n\nexport function Link({\n  children,\n  className,\n  href,\n  target,\n  rel,\n  ...rest\n}: LinkProps) {\n  const safeHref = UNSAFE_HREF_PATTERN.test(href ?? \"\") ? undefined : href;\n  const effectiveTarget = target ?? \"_blank\";\n  const relTokens = new Set([\n    \"noopener\",\n    \"noreferrer\",\n    ...(rel?.split(/\\s+/).filter(Boolean) ?? []),\n  ]);\n  const safeRel = effectiveTarget === \"_blank\" ? [...relTokens].join(\" \") : rel;\n  return (\n    <a\n      className={clsx(\n        \"text-blue-600 dark:text-blue-400 hover:underline underline-offset-1\",\n        className,\n      )}\n      href={safeHref}\n      target={effectiveTarget}\n      rel={safeRel}\n      {...rest}\n    >\n      {children}\n    </a>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/hooks/use-theme.ts",
      "content": "import { useEffect, useState } from \"react\";\n\nexport const useTheme = () => {\n  const [isDark, setDark] = useState(false);\n\n  useEffect(() => {\n    const checkDarkMode = () => {\n      setDark(document.documentElement.classList.contains(\"dark\"));\n    };\n    checkDarkMode();\n\n    const observer = new MutationObserver(checkDarkMode);\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    return () => {\n      observer.disconnect();\n    };\n  }, []);\n\n  return { isDark };\n};\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:block"
}