{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "message-bubble",
  "title": "Message Bubble",
  "description": "Chat message bubble and message group with a tail on the speaker's side.",
  "dependencies": [
    "class-variance-authority@^0.7.1",
    "lucide-react@^1.27.0"
  ],
  "registryDependencies": [
    "@pestoui/style",
    "@pestoui/utils"
  ],
  "files": [
    {
      "path": "pestoui/components/message-bubble.tsx",
      "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { Check, Loader2 } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst STATUS_LABEL = {\n  sending: \"Sending\",\n  sent: \"Sent\",\n  delivered: \"Delivered\",\n  read: \"Read\",\n} as const\n\ntype MessageStatus = keyof typeof STATUS_LABEL\n\n/* The corner nearest the speaker is tightened to 6px so the bubble reads as a\n   tail. The other three use an explicit 18px rather than the 2xl token: at\n   1.8rem the token overruns half the height of a single-line bubble, and CSS\n   then scales *every* corner down proportionally — the tail included — leaving\n   a lozenge instead of a squircle. */\nconst messageBubbleVariants = cva(\n  \"w-fit max-w-full rounded-[18px] px-3.5 py-2.5 text-body leading-normal shadow-soft [overflow-wrap:anywhere]\",\n  {\n    variants: {\n      side: {\n        incoming: \"rounded-bl-[6px] bg-secondary text-foreground\",\n        outgoing: \"rounded-br-[6px] bg-primary text-primary-foreground\",\n      },\n    },\n    defaultVariants: {\n      side: \"incoming\",\n    },\n  }\n)\n\n/* A message arrives, it does not appear. Six pixels of rise, a four pixel blur\n   resolving to nothing, and a fade — all on the panel spring, which is firm\n   enough that a run of them landing together never reads as bouncing.\n\n   The blur is what separates this from a slide: it makes the bubble materialise\n   into the thread rather than travel through it, and four pixels is the small\n   surface end of the range. Enter only, and that is not an omission — a bubble\n   is never removed, so there is no exit to be asymmetric with. */\nconst messageBubbleEnter =\n  \"animate-in fade-in-0 slide-in-from-bottom-[6px] blur-in-[4px] anim-spring-panel ease-spring-panel\"\n\n/* Ticks press in rather than blinking on — the same glyph pop the menus use for\n   their check marks, since a delivery mark is the same kind of small\n   confirmation appearing in place. */\nconst messageTickPop = \"animate-in fade-in-0 zoom-in-50 anim-spring ease-spring-press\"\n\n/* Delivery ticks. Blue is the accent for the settled state — a read receipt —\n   and nothing else in the thread competes for it. The colour is transitioned\n   rather than swapped: read is the end of the message's life, so it should\n   arrive as a settle. */\nfunction MessageStatusMark({ status }: { status: MessageStatus }) {\n  return (\n    <span\n      data-slot=\"message-status\"\n      data-status={status}\n      className={cn(\n        \"inline-flex shrink-0 items-center transition-colors duration-base ease-out-expo\",\n        status === \"read\" && \"text-info\"\n      )}\n    >\n      {status === \"sending\" ? (\n        <Loader2\n          aria-hidden=\"true\"\n          className=\"size-3.5 animate-spin [animation-duration:1.4s]\"\n        />\n      ) : (\n        <>\n          <Check aria-hidden=\"true\" className={cn(\"size-3.5\", messageTickPop)} />\n          {(status === \"delivered\" || status === \"read\") && (\n            <Check\n              aria-hidden=\"true\"\n              className={cn(\"-ml-2 size-3.5\", messageTickPop)}\n            />\n          )}\n        </>\n      )}\n      <span className=\"sr-only\">{STATUS_LABEL[status]}</span>\n    </span>\n  )\n}\n\nfunction MessageBubble({\n  side = \"incoming\",\n  children,\n  timestamp,\n  author,\n  avatar,\n  status,\n  className,\n  ...props\n}: React.ComponentProps<\"article\"> &\n  VariantProps<typeof messageBubbleVariants> & {\n    timestamp?: string\n    author?: React.ReactNode\n    avatar?: React.ReactNode\n    status?: MessageStatus\n  }) {\n  return (\n    <article\n      data-slot=\"message-bubble\"\n      data-side={side}\n      className={cn(\n        \"group/message flex w-full flex-col items-start data-[side=outgoing]:items-end\",\n        messageBubbleEnter,\n        className\n      )}\n      {...props}\n    >\n      {author && (\n        <span\n          data-slot=\"message-author\"\n          className=\"mb-1 px-1 text-caption font-medium text-muted-foreground\"\n        >\n          {author}\n        </span>\n      )}\n\n      {/* Avatar rides the bottom edge of the bubble, on the speaker's side. */}\n      <div className=\"flex max-w-[min(32rem,85%)] items-end gap-2 group-data-[side=outgoing]/message:flex-row-reverse\">\n        {avatar && (\n          <span\n            data-slot=\"message-avatar\"\n            className=\"flex size-8 shrink-0 items-center justify-center *:data-[slot=avatar]:size-8\"\n          >\n            {avatar}\n          </span>\n        )}\n        <div\n          data-slot=\"message-bubble-body\"\n          className={cn(messageBubbleVariants({ side }))}\n        >\n          {children}\n        </div>\n      </div>\n\n      {(timestamp || status) && (\n        <span\n          data-slot=\"message-meta\"\n          className=\"mt-1 flex items-center gap-1.5 px-1 text-caption text-muted-foreground\"\n        >\n          {timestamp && <span className=\"tabular-nums\">{timestamp}</span>}\n          {status && <MessageStatusMark status={status} />}\n        </span>\n      )}\n    </article>\n  )\n}\n\n/* A run of consecutive messages from one speaker: tighter spacing, and only the\n   final bubble carries the avatar, the tail corner and the speaker's name up\n   top. Earlier avatars go invisible rather than hidden so they keep their column\n   and the stack stays flush. */\nfunction MessageGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"message-group\"\n      className={cn(\n        \"flex w-full flex-col gap-1\",\n        \"[&>[data-slot=message-bubble]:not(:last-child)_[data-slot=message-avatar]]:invisible\",\n        \"[&>[data-slot=message-bubble]:not(:first-child)_[data-slot=message-author]]:hidden\",\n        \"[&>[data-side=incoming]:not(:last-child)_[data-slot=message-bubble-body]]:rounded-bl-[18px]\",\n        \"[&>[data-side=outgoing]:not(:last-child)_[data-slot=message-bubble-body]]:rounded-br-[18px]\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { MessageBubble, MessageGroup, messageBubbleVariants }\nexport type { MessageStatus }\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}