{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "message-bubble",
  "title": "Message Bubble",
  "author": "mcpcn",
  "description": "Chat message bubbles with text, image, voice, and reaction variants.",
  "dependencies": [
    "@base-ui/react",
    "lucide-react"
  ],
  "registryDependencies": [
    "dropdown-menu",
    "https://mcpcn.dev/r/apps-sdk-theme.json"
  ],
  "files": [
    {
      "path": "registry/messaging/message-bubble.tsx",
      "content": "\"use client\";\n\nimport { Check, CheckCheck, Smile } from \"lucide-react\";\nimport {\n  createContext,\n  createElement,\n  useContext,\n  useRef,\n  useState,\n} from \"react\";\nimport type { ImgHTMLAttributes, ReactNode } from \"react\";\n\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { cn } from \"@/lib/utils\";\n\nconst BlockImage = (props: ImgHTMLAttributes<HTMLImageElement>) =>\n  createElement(\"img\", props);\n\n/**\n * Internal avatar component options.\n * @interface InternalAvatarOptions\n * @property {string} [src] - Avatar image URL\n * @property {string} fallback - Fallback letter when image fails or is missing\n * @property {string} [className] - Additional CSS classes\n */\ninterface InternalAvatarOptions {\n  src?: string;\n  fallback: string;\n  className?: string;\n}\n\nconst Avatar = ({ src, fallback, className }: InternalAvatarOptions) => {\n  const [imgError, setImgError] = useState(false);\n\n  if (src && !imgError) {\n    return (\n      <BlockImage\n        src={src}\n        alt={fallback}\n        onError={() => setImgError(true)}\n        className={cn(\"size-8 rounded-full object-cover shrink-0\", className)}\n      />\n    );\n  }\n\n  return (\n    <div\n      className={cn(\n        \"size-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-semibold shrink-0\",\n        className\n      )}\n    >\n      {fallback}\n    </div>\n  );\n};\n\ninterface MessageMetadataProps {\n  isOwn: boolean;\n  status?: \"sent\" | \"delivered\" | \"read\";\n  time?: string;\n}\n\nconst MessageMetadata = ({ isOwn, status, time }: MessageMetadataProps) => (\n  <div className={cn(\"mt-1 flex items-center gap-1\", isOwn && \"justify-end\")}>\n    {time && <span className=\"text-[10px] text-muted-foreground\">{time}</span>}\n    {isOwn && status && (\n      <span className=\"text-muted-foreground\">\n        {status === \"sent\" && <Check className=\"size-3\" />}\n        {status === \"delivered\" && <CheckCheck className=\"size-3\" />}\n        {status === \"read\" && <CheckCheck className=\"size-3 text-foreground\" />}\n      </span>\n    )}\n  </div>\n);\n\nexport interface MessageBubbleProps {\n  children?: ReactNode;\n  data?: {\n    /** Message text content to display. */\n    content?: string;\n    /** URL for the sender's avatar image. */\n    avatarUrl?: string;\n    /** Fallback letter to display when avatar image is unavailable. */\n    avatarFallback?: string;\n    /** Display name of the message author. */\n    author?: string;\n    /** Time display string (e.g., \"10:30 AM\"). */\n    time?: string;\n  };\n  appearance?: {\n    /**\n     * Whether this message is from the current user.\n     * @default false\n     */\n    isOwn?: boolean;\n  };\n  control?: {\n    /** Message delivery status indicator. */\n    status?: \"sent\" | \"delivered\" | \"read\";\n  };\n}\n\nconst DEFAULT_TEXT_MESSAGE = {\n  avatarFallback: \"S\",\n  avatarUrl: \"https://picsum.photos/seed/sarah/150/150\",\n  content: \"Hey! How are you doing today?\",\n  time: \"Dec 8, 10:30 AM\",\n} satisfies NonNullable<MessageBubbleProps[\"data\"]>;\n\nconst MessageBubbleContext = createContext<MessageBubbleProps | null>(null);\n\nexport const useMessageBubble = () => {\n  const context = useContext(MessageBubbleContext);\n  if (!context) {\n    throw new Error(\n      \"MessageBubble components must be used within MessageBubble\"\n    );\n  }\n  return context;\n};\n\nconst MessageBubbleView = ({\n  data,\n  appearance,\n  control,\n}: MessageBubbleProps) => {\n  const resolved: NonNullable<MessageBubbleProps[\"data\"]> =\n    data ?? DEFAULT_TEXT_MESSAGE;\n  const { content } = resolved;\n  const { avatarFallback } = resolved;\n  const { avatarUrl } = resolved;\n  const { time } = resolved;\n  const { isOwn = false } = appearance ?? {};\n  const { status } = control ?? {};\n  return (\n    <div className={cn(\"flex gap-2\", isOwn && \"flex-row-reverse\")}>\n      {!isOwn && avatarFallback && (\n        <Avatar src={avatarUrl} fallback={avatarFallback} />\n      )}\n      <div className={cn(\"max-w-[75%]\", isOwn && \"items-end\")}>\n        {content && (\n          <div\n            className={cn(\n              \"rounded-2xl px-4 py-2\",\n              isOwn\n                ? \"bg-primary text-primary-foreground rounded-br-md\"\n                : \"bg-muted rounded-bl-md\"\n            )}\n          >\n            <p className=\"text-sm\">{content}</p>\n          </div>\n        )}\n        <MessageMetadata isOwn={isOwn} status={status} time={time} />\n      </div>\n    </div>\n  );\n};\n\nexport const MessageBubbleContent = (props: MessageBubbleProps) => {\n  const context = useMessageBubble();\n  return <MessageBubbleView {...context} {...props} />;\n};\n\nconst MessageBubbleRoot = ({\n  children,\n  ...props\n}: MessageBubbleProps & { children: React.ReactNode }) => (\n  <MessageBubbleContext.Provider value={props}>\n    {children}\n  </MessageBubbleContext.Provider>\n);\n\nexport const MessageBubble = MessageBubbleRoot;\n\nexport interface ImageMessageBubbleProps {\n  data?: {\n    /** URL of the image to display. */\n    image?: string;\n    /** Optional caption text below the image. */\n    content?: string;\n    /** URL for the sender's avatar image. */\n    avatarUrl?: string;\n    /** Fallback letter to display when avatar image is unavailable. */\n    avatarFallback?: string;\n    /** Display name of the message author. */\n    author?: string;\n    /** Time display string (e.g., \"10:32 AM\"). */\n    time?: string;\n  };\n  appearance?: {\n    /**\n     * Whether this message is from the current user.\n     * @default false\n     */\n    isOwn?: boolean;\n  };\n  control?: {\n    /** Message delivery status indicator. */\n    status?: \"sent\" | \"delivered\" | \"read\";\n  };\n}\n\nconst DEFAULT_IMAGE_MESSAGE = {\n  avatarFallback: \"A\",\n  avatarUrl: \"https://picsum.photos/seed/alex/150/150\",\n  content: \"Check out this view!\",\n  image: \"https://picsum.photos/seed/message-attachment/400/300\",\n  time: \"Dec 8, 2:45 PM\",\n} satisfies NonNullable<ImageMessageBubbleProps[\"data\"]>;\n\nexport const ImageMessageBubble = ({\n  data,\n  appearance,\n  control,\n}: ImageMessageBubbleProps) => {\n  const resolved: NonNullable<ImageMessageBubbleProps[\"data\"]> =\n    data ?? DEFAULT_IMAGE_MESSAGE;\n  const { image } = resolved;\n  const { content } = resolved;\n  const { avatarFallback } = resolved;\n  const { avatarUrl } = resolved;\n  const { time } = resolved;\n  const { isOwn = false } = appearance ?? {};\n  const { status } = control ?? {};\n  return (\n    <div className={cn(\"flex gap-2\", isOwn && \"flex-row-reverse\")}>\n      {!isOwn && avatarFallback && (\n        <Avatar src={avatarUrl} fallback={avatarFallback} />\n      )}\n      <div className={cn(\"max-w-[75%]\", isOwn && \"items-end\")}>\n        {image && (\n          <div\n            className={cn(\n              \"rounded-2xl overflow-hidden\",\n              isOwn ? \"rounded-br-md\" : \"rounded-bl-md\"\n            )}\n          >\n            <BlockImage\n              src={image}\n              alt={content || \"Shared image in chat\"}\n              className=\"w-full max-w-[280px] h-auto object-cover\"\n            />\n            {content && (\n              <div\n                className={cn(\n                  \"px-3 py-2\",\n                  isOwn ? \"bg-primary text-primary-foreground\" : \"bg-muted\"\n                )}\n              >\n                <p className=\"text-sm\">{content}</p>\n              </div>\n            )}\n          </div>\n        )}\n        <MessageMetadata isOwn={isOwn} status={status} time={time} />\n      </div>\n    </div>\n  );\n};\n\nexport interface MessageWithReactionsProps {\n  data?: {\n    /** Message text content to display. */\n    content?: string;\n    /** URL for the sender's avatar image. */\n    avatarUrl?: string;\n    /** Fallback letter to display when avatar image is unavailable. */\n    avatarFallback?: string;\n    /** Display name of the message author. */\n    author?: string;\n    /** Time display string (e.g., \"2:45 PM\"). */\n    time?: string;\n    /** Array of reactions with emoji and count. */\n    reactions?: { emoji: string; count: number }[];\n  };\n  actions?: {\n    /** Called when the user adds or toggles a reaction emoji. */\n    onReact?: (emoji: string) => void;\n  };\n  appearance?: {\n    /**\n     * Whether this message is from the current user.\n     * @default false\n     */\n    isOwn?: boolean;\n  };\n}\n\nconst DEFAULT_REACTION_MESSAGE = {\n  avatarFallback: \"T\",\n  content: \"We just hit 10,000 users!\",\n  reactions: [\n    { count: 5, emoji: \"🎉\" },\n    { count: 3, emoji: \"❤️\" },\n    { count: 2, emoji: \"👏\" },\n  ],\n  time: \"Dec 8, 4:20 PM\",\n} satisfies NonNullable<MessageWithReactionsProps[\"data\"]>;\n\n/**\n * Available emoji options for reactions.\n * @constant\n */\nconst availableEmojis = [\n  \"❤️\",\n  \"👍\",\n  \"👎\",\n  \"😂\",\n  \"😮\",\n  \"😢\",\n  \"🎉\",\n  \"🔥\",\n  \"👏\",\n  \"💯\",\n];\n\nexport const MessageWithReactions = ({\n  data,\n  actions,\n  appearance,\n}: MessageWithReactionsProps) => {\n  const resolved: NonNullable<MessageWithReactionsProps[\"data\"]> =\n    data ?? DEFAULT_REACTION_MESSAGE;\n  const { content } = resolved;\n  const { avatarFallback } = resolved;\n  const { avatarUrl } = resolved;\n  const { time } = resolved;\n  const initialReactions = resolved.reactions ?? [];\n  const { onReact } = actions ?? {};\n  const { isOwn = false } = appearance ?? {};\n  const [reactions, setReactions] = useState(initialReactions);\n  const [userReactions, setUserReactions] = useState<Set<string>>(new Set());\n\n  const handleReact = (emoji: string) => {\n    const hasUserReacted = userReactions.has(emoji);\n    const existingIndex = reactions.findIndex((r) => r.emoji === emoji);\n\n    if (hasUserReacted) {\n      if (existingIndex !== -1) {\n        const updated = [...reactions];\n        if (updated[existingIndex].count <= 1) {\n          updated.splice(existingIndex, 1);\n        } else {\n          updated[existingIndex] = {\n            ...updated[existingIndex],\n            count: updated[existingIndex].count - 1,\n          };\n        }\n        setReactions(updated);\n      }\n      setUserReactions((prev) => {\n        const next = new Set(prev);\n        next.delete(emoji);\n        return next;\n      });\n    } else {\n      if (existingIndex === -1) {\n        setReactions([...reactions, { count: 1, emoji }]);\n      } else {\n        const updated = [...reactions];\n        updated[existingIndex] = {\n          ...updated[existingIndex],\n          count: updated[existingIndex].count + 1,\n        };\n        setReactions(updated);\n      }\n      setUserReactions((prev) => new Set(prev).add(emoji));\n    }\n    onReact?.(emoji);\n  };\n\n  return (\n    <div className={cn(\"flex gap-2\", isOwn && \"flex-row-reverse\")}>\n      {!isOwn && avatarFallback && (\n        <Avatar src={avatarUrl} fallback={avatarFallback} />\n      )}\n      <div className={cn(\"max-w-[75%]\", isOwn && \"items-end\")}>\n        {content && (\n          <div\n            className={cn(\n              \"rounded-2xl px-4 py-2\",\n              isOwn\n                ? \"bg-primary text-primary-foreground rounded-br-md\"\n                : \"bg-muted rounded-bl-md\"\n            )}\n          >\n            <p className=\"text-sm\">{content}</p>\n          </div>\n        )}\n        <div\n          className={cn(\n            \"flex items-center gap-1 mt-1.5\",\n            isOwn ? \"justify-end\" : \"justify-start\"\n          )}\n        >\n          {reactions && reactions.length > 0 && (\n            <>\n              {reactions.map((reaction, index) => (\n                <button\n                  key={index}\n                  onClick={() => handleReact(reaction.emoji)}\n                  aria-label={`${userReactions.has(reaction.emoji) ? \"Remove\" : \"Add\"} ${reaction.emoji} reaction, ${reaction.count} ${reaction.count === 1 ? \"reaction\" : \"reactions\"}`}\n                  className={cn(\n                    \"inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs transition-colors cursor-pointer\",\n                    userReactions.has(reaction.emoji)\n                      ? \"bg-primary/15 border border-primary/50\"\n                      : \"bg-card border hover:bg-muted\"\n                  )}\n                >\n                  {reaction.emoji}\n                  <span\n                    className={cn(\n                      userReactions.has(reaction.emoji)\n                        ? \"text-primary\"\n                        : \"text-muted-foreground\"\n                    )}\n                  >\n                    {reaction.count}\n                  </span>\n                </button>\n              ))}\n            </>\n          )}\n          <DropdownMenu>\n            <DropdownMenuTrigger\n              render={\n                <button\n                  aria-label=\"Add reaction\"\n                  className=\"inline-flex items-center justify-center size-6 bg-card border rounded-full hover:bg-muted transition-colors cursor-pointer\"\n                />\n              }\n            >\n              <Smile className=\"size-3.5 text-muted-foreground\" />\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"start\" className=\"p-2\">\n              <div className=\"grid grid-cols-5 gap-1\">\n                {availableEmojis.map((emoji) => (\n                  <button\n                    key={emoji}\n                    onClick={() => handleReact(emoji)}\n                    aria-label={`React with ${emoji}`}\n                    className=\"size-8 flex items-center justify-center text-lg hover:bg-muted rounded transition-colors cursor-pointer\"\n                  >\n                    {emoji}\n                  </button>\n                ))}\n              </div>\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </div>\n        <div\n          className={cn(\"flex items-center gap-1 mt-1\", isOwn && \"justify-end\")}\n        >\n          {time && (\n            <span className=\"text-[10px] text-muted-foreground\">{time}</span>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n};\n\nexport interface VoiceMessageBubbleProps {\n  data?: {\n    /** Total duration display string (e.g., \"0:42\"). */\n    duration?: string;\n    /** URL for the sender's avatar image. */\n    avatarUrl?: string;\n    /** Fallback letter to display when avatar image is unavailable. */\n    avatarFallback?: string;\n    /** Display name of the message author. */\n    author?: string;\n    /** Time display string (e.g., \"3:15 PM\"). */\n    time?: string;\n    /** URL of the audio file to play. */\n    audioSrc?: string;\n  };\n  appearance?: {\n    /**\n     * Whether this message is from the current user.\n     * @default false\n     */\n    isOwn?: boolean;\n  };\n  control?: {\n    /** Message delivery status indicator. */\n    status?: \"sent\" | \"delivered\" | \"read\";\n  };\n}\n\nconst DEFAULT_VOICE_MESSAGE = {\n  avatarFallback: \"M\",\n  avatarUrl: \"https://picsum.photos/seed/mickael/150/150\",\n  duration: \"0:42\",\n  time: \"Dec 8, 3:15 PM\",\n} satisfies NonNullable<VoiceMessageBubbleProps[\"data\"]>;\n\nexport const VoiceMessageBubble = ({\n  data,\n  appearance,\n  control,\n}: VoiceMessageBubbleProps) => {\n  const resolved: NonNullable<VoiceMessageBubbleProps[\"data\"]> =\n    data ?? DEFAULT_VOICE_MESSAGE;\n  const { duration } = resolved;\n  const { avatarFallback } = resolved;\n  const { avatarUrl } = resolved;\n  const { time } = resolved;\n  const { audioSrc } = resolved;\n  const { isOwn = false } = appearance ?? {};\n  const { status } = control ?? {};\n  const [isPlaying, setIsPlaying] = useState(false);\n  const [progress, setProgress] = useState(0);\n  const [currentTime, setCurrentTime] = useState(\"0:00\");\n  const audioRef = useRef<HTMLAudioElement>(null);\n\n  const togglePlay = () => {\n    if (audioRef.current) {\n      if (isPlaying) {\n        audioRef.current.pause();\n      } else {\n        audioRef.current.play();\n      }\n      setIsPlaying(!isPlaying);\n    }\n  };\n\n  const handleTimeUpdate = () => {\n    if (audioRef.current) {\n      const current = audioRef.current.currentTime;\n      const total = audioRef.current.duration || 1;\n      setProgress((current / total) * 100);\n      const mins = Math.floor(current / 60);\n      const secs = Math.floor(current % 60);\n      setCurrentTime(`${mins}:${secs.toString().padStart(2, \"0\")}`);\n    }\n  };\n\n  const handleEnded = () => {\n    setIsPlaying(false);\n    setProgress(0);\n    setCurrentTime(\"0:00\");\n  };\n\n  return (\n    <div className={cn(\"flex gap-2\", isOwn && \"flex-row-reverse\")}>\n      <audio\n        ref={audioRef}\n        src={audioSrc}\n        onTimeUpdate={handleTimeUpdate}\n        onEnded={handleEnded}\n        preload=\"metadata\"\n      >\n        <track kind=\"captions\" />\n      </audio>\n      {!isOwn && avatarFallback && (\n        <Avatar src={avatarUrl} fallback={avatarFallback} />\n      )}\n      <div className={cn(\"max-w-[75%]\", isOwn && \"items-end\")}>\n        <div\n          className={cn(\n            \"rounded-2xl px-4 py-3 flex items-center gap-3\",\n            isOwn\n              ? \"bg-primary text-primary-foreground rounded-br-md\"\n              : \"bg-muted rounded-bl-md\"\n          )}\n        >\n          <button\n            onClick={togglePlay}\n            aria-label={\n              isPlaying ? \"Pause voice message\" : \"Play voice message\"\n            }\n            className={cn(\n              \"size-8 rounded-full flex items-center justify-center shrink-0 transition-colors cursor-pointer\",\n              isOwn\n                ? \"bg-primary-foreground/20 hover:bg-primary-foreground/30\"\n                : \"bg-foreground/10 hover:bg-foreground/20\"\n            )}\n          >\n            {isPlaying ? (\n              <svg className=\"size-4\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n                <path d=\"M6 4h4v16H6V4zm8 0h4v16h-4V4z\" />\n              </svg>\n            ) : (\n              <svg\n                className=\"size-4 ml-0.5\"\n                viewBox=\"0 0 24 24\"\n                fill=\"currentColor\"\n              >\n                <path d=\"M8 5v14l11-7z\" />\n              </svg>\n            )}\n          </button>\n          <div className=\"flex-1 flex items-center gap-2\">\n            <div className=\"flex-1 h-1 bg-current/20 rounded-full overflow-hidden\">\n              <div\n                className=\"h-full bg-current rounded-full transition-all duration-100\"\n                style={{ width: `${progress || 33}%` }}\n              />\n            </div>\n            <span className=\"text-xs font-medium\">\n              {isPlaying ? currentTime : (duration ?? \"0:00\")}\n            </span>\n          </div>\n        </div>\n        <MessageMetadata isOwn={isOwn} status={status} time={time} />\n      </div>\n    </div>\n  );\n};\n",
      "type": "registry:block",
      "target": "components/ui/message-bubble.tsx"
    }
  ],
  "categories": [
    "messaging"
  ],
  "type": "registry:block"
}