{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "chat-thread",
  "title": "Chat Thread",
  "type": "registry:block",
  "files": [
    {
      "path": "packages/registry-svelte/blocks/chat-thread/ChatThread.svelte",
      "content": "<script lang=\"ts\">\n  import { tick } from 'svelte'\n  import {\n    ArrowRight,\n    AtSign,\n    Check,\n    CheckCheck,\n    MoreVertical,\n    Paperclip,\n    Phone,\n    Smile,\n    Video,\n  } from '@lucide/svelte'\n  import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/ui/avatar'\n  import { Button } from '$lib/components/ui/button'\n  import { Textarea } from '$lib/components/ui/textarea'\n\n  type MessageStatus = 'sent' | 'delivered' | 'read'\n\n  interface Message {\n    id: string\n    author: 'me' | 'them'\n    body: string\n    sentAt: Date\n    status?: MessageStatus\n  }\n\n  const now = new Date()\n  const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)\n\n  const peer = {\n    name: 'Marcus Rivera',\n    handle: '@marcus',\n    avatarUrl: '',\n    initials: 'MR',\n    online: true,\n    lastSeen: 'Active now',\n  }\n\n  let messages = $state<Message[]>([\n    {\n      id: 'm1',\n      author: 'them',\n      body: 'Hey — did you get a chance to look at the onboarding doc?',\n      sentAt: new Date(yesterday.getTime() + 9 * 60 * 60 * 1000 + 14 * 60 * 1000),\n    },\n    {\n      id: 'm2',\n      author: 'me',\n      body: 'Yeah, just finished. Two small notes on the time-off flow but otherwise looks solid.',\n      sentAt: new Date(yesterday.getTime() + 9 * 60 * 60 * 1000 + 21 * 60 * 1000),\n      status: 'read',\n    },\n    {\n      id: 'm3',\n      author: 'them',\n      body: 'Perfect. Drop them in the doc and I will action this afternoon.',\n      sentAt: new Date(yesterday.getTime() + 9 * 60 * 60 * 1000 + 23 * 60 * 1000),\n    },\n    {\n      id: 'm4',\n      author: 'me',\n      body: 'Done. Also — are we still on for the dashboard review tomorrow at 10?',\n      sentAt: new Date(now.getTime() - 42 * 60 * 1000),\n      status: 'read',\n    },\n    {\n      id: 'm5',\n      author: 'them',\n      body: 'Yes! I will bring the kanban deck. Want to push it to 10:30 so we can join from the standup?',\n      sentAt: new Date(now.getTime() - 8 * 60 * 1000),\n    },\n  ])\n\n  let draft = $state('')\n  let scrollRoot = $state<HTMLElement | null>(null)\n  let peerTyping = $state(false)\n\n  const groupedMessages = $derived.by(() => {\n    const groups: Array<{ label: string; items: Message[] }> = []\n    for (const m of messages) {\n      const label = dayLabel(m.sentAt)\n      const last = groups[groups.length - 1]\n      if (last && last.label === label) last.items.push(m)\n      else groups.push({ label, items: [m] })\n    }\n    return groups\n  })\n\n  function dayLabel(d: Date): string {\n    const sameDay = d.toDateString() === now.toDateString()\n    if (sameDay) return 'Today'\n    const sameYesterday = d.toDateString() === yesterday.toDateString()\n    if (sameYesterday) return 'Yesterday'\n    return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })\n  }\n\n  function formatTime(d: Date): string {\n    return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })\n  }\n\n  async function send() {\n    const body = draft.trim()\n    if (!body) return\n    messages.push({\n      id: `m${Date.now()}`,\n      author: 'me',\n      body,\n      sentAt: new Date(),\n      status: 'sent',\n    })\n    draft = ''\n    await tick()\n    scrollToBottom()\n    peerTyping = true\n    window.setTimeout(() => {\n      peerTyping = false\n    }, 1800)\n  }\n\n  function onComposerKey(e: KeyboardEvent) {\n    if (e.key === 'Enter' && !e.shiftKey) {\n      e.preventDefault()\n      send()\n    }\n  }\n\n  function scrollToBottom() {\n    if (!scrollRoot) return\n    scrollRoot.scrollTop = scrollRoot.scrollHeight\n  }\n\n  // Keep the thread pinned to the latest message, including first paint.\n  $effect(() => {\n    void messages.length\n    tick().then(scrollToBottom)\n  })\n</script>\n\n<div\n  data-slot=\"chat-thread\"\n  class=\"bg-card text-card-foreground flex h-[42.5rem] w-full flex-col overflow-hidden rounded-xl border shadow-sm\"\n>\n  <header class=\"flex h-14 shrink-0 items-center justify-between gap-3 border-b px-4\">\n    <div class=\"flex min-w-0 items-center gap-3\">\n      <div class=\"relative\">\n        <Avatar class=\"size-9\">\n          {#if peer.avatarUrl}\n            <AvatarImage src={peer.avatarUrl} alt={peer.name} />\n          {/if}\n          <AvatarFallback class=\"text-xs font-medium\">{peer.initials}</AvatarFallback>\n        </Avatar>\n        {#if peer.online}\n          <span class=\"bg-success ring-card absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2\"></span>\n        {/if}\n      </div>\n      <div class=\"min-w-0\">\n        <p class=\"truncate text-sm font-semibold tracking-tight\">{peer.name}</p>\n        <p class=\"text-muted-foreground truncate text-xs\">{peer.lastSeen}</p>\n      </div>\n    </div>\n    <div class=\"flex items-center gap-1\">\n      <Button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"Voice call\">\n        <Phone class=\"size-4\" />\n      </Button>\n      <Button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"Video call\">\n        <Video class=\"size-4\" />\n      </Button>\n      <Button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"More options\">\n        <MoreVertical class=\"size-4\" />\n      </Button>\n    </div>\n  </header>\n\n  <div bind:this={scrollRoot} class=\"chat-scroll flex-1 space-y-5 overflow-y-auto px-4 py-4\">\n    {#each groupedMessages as group (group.label)}\n      <div class=\"space-y-3\">\n        <div class=\"flex items-center justify-center\">\n          <span\n            class=\"text-muted-foreground bg-muted/60 rounded-full px-2.5 py-0.5 text-xs font-medium tracking-wide uppercase\"\n          >\n            {group.label}\n          </span>\n        </div>\n        {#each group.items as m (m.id)}\n          <div class=\"flex items-end gap-2 {m.author === 'me' ? 'justify-end' : 'justify-start'}\">\n            {#if m.author === 'them'}\n              <Avatar class=\"size-7\">\n                <AvatarFallback class=\"text-xs\">{peer.initials}</AvatarFallback>\n              </Avatar>\n            {/if}\n            <div class=\"flex max-w-[80%] flex-col gap-1 {m.author === 'me' ? 'items-end' : 'items-start'}\">\n              <div\n                class=\"rounded-2xl px-3.5 py-2 text-sm leading-relaxed shadow-sm {m.author === 'me'\n                  ? 'bg-primary text-primary-foreground rounded-br-md'\n                  : 'bg-muted text-foreground rounded-bl-md'}\"\n              >\n                {m.body}\n              </div>\n              <div class=\"text-muted-foreground flex items-center gap-1 px-1 text-xs tabular-nums\">\n                <span>{formatTime(m.sentAt)}</span>\n                {#if m.author === 'me' && m.status === 'read'}\n                  <CheckCheck class=\"text-info size-3\" />\n                {:else if m.author === 'me' && m.status === 'delivered'}\n                  <CheckCheck class=\"size-3\" />\n                {:else if m.author === 'me' && m.status === 'sent'}\n                  <Check class=\"size-3\" />\n                {/if}\n              </div>\n            </div>\n          </div>\n        {/each}\n      </div>\n    {/each}\n\n    {#if peerTyping}\n      <div class=\"flex items-end gap-2\">\n        <Avatar class=\"size-7\">\n          <AvatarFallback class=\"text-xs\">{peer.initials}</AvatarFallback>\n        </Avatar>\n        <div class=\"bg-muted text-muted-foreground flex items-center gap-1 rounded-2xl rounded-bl-md px-3 py-2.5\">\n          <span class=\"typing-dot\"></span>\n          <span class=\"typing-dot\" style=\"animation-delay: 0.15s\"></span>\n          <span class=\"typing-dot\" style=\"animation-delay: 0.3s\"></span>\n        </div>\n      </div>\n    {/if}\n  </div>\n\n  <div class=\"bg-muted/20 border-t px-4 py-3\">\n    <div\n      class=\"bg-background border-input/60 focus-within:border-primary/40 focus-within:ring-primary/10 group flex flex-col overflow-hidden rounded-2xl border shadow-sm transition-colors duration-150 focus-within:shadow-md focus-within:ring-4\"\n    >\n      <Textarea\n        bind:value={draft}\n        placeholder={`Message ${peer.name.split(' ')[0]}`}\n        rows=\"1\"\n        variant=\"plain\"\n        class=\"placeholder:text-muted-foreground/70 max-h-40 min-h-[44px] resize-none border-0 bg-transparent px-4 pt-3 pb-1 text-sm leading-relaxed shadow-none focus-visible:ring-0\"\n        onkeydown={onComposerKey}\n      />\n      <div class=\"flex items-center justify-between gap-2 px-2 pb-2\">\n        <div class=\"flex items-center gap-0.5\">\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            class=\"text-muted-foreground hover:text-foreground size-8\"\n            aria-label=\"Attach file\"\n          >\n            <Paperclip class=\"size-4\" />\n          </Button>\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            class=\"text-muted-foreground hover:text-foreground size-8\"\n            aria-label=\"Emoji\"\n          >\n            <Smile class=\"size-4\" />\n          </Button>\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            class=\"text-muted-foreground hover:text-foreground size-8\"\n            aria-label=\"Mention\"\n          >\n            <AtSign class=\"size-4\" />\n          </Button>\n        </div>\n        <div class=\"flex items-center gap-2\">\n          <kbd\n            class=\"bg-muted text-muted-foreground hidden h-5 items-center gap-0.5 rounded border px-1.5 font-mono text-xs sm:inline-flex\"\n          >\n            <span>↵</span>\n          </kbd>\n          <Button\n            class=\"h-8 gap-1.5 rounded-lg px-3 text-xs font-medium\"\n            disabled={!draft.trim()}\n            aria-label=\"Send\"\n            onclick={send}\n          >\n            Send\n            <ArrowRight class=\"size-3.5\" />\n          </Button>\n        </div>\n      </div>\n    </div>\n    <p class=\"text-muted-foreground/70 mt-2 text-center text-xs\">\n      <kbd class=\"bg-muted/60 rounded px-1 py-0.5 font-mono text-xs\">Enter</kbd>\n      to send\n      <span class=\"mx-1.5 opacity-50\">·</span>\n      <kbd class=\"bg-muted/60 rounded px-1 py-0.5 font-mono text-xs\">Shift</kbd>\n      +\n      <kbd class=\"bg-muted/60 rounded px-1 py-0.5 font-mono text-xs\">↵</kbd>\n      for newline\n    </p>\n  </div>\n</div>\n\n<style>\n  /* Thin overlay scrollbar + typing dots. Keyframes and scrollbar\n     pseudo-elements can't be Tailwind utilities, so they live here. */\n  .chat-scroll::-webkit-scrollbar {\n    width: 6px;\n  }\n  .chat-scroll::-webkit-scrollbar-thumb {\n    background: var(--border);\n    border-radius: 3px;\n  }\n  .typing-dot {\n    display: inline-block;\n    width: 6px;\n    height: 6px;\n    border-radius: 9999px;\n    background: currentColor;\n    opacity: 0.5;\n    animation: typing-bounce 1s infinite ease-in-out;\n  }\n  @keyframes typing-bounce {\n    0%,\n    80%,\n    100% {\n      transform: translateY(0);\n      opacity: 0.35;\n    }\n    40% {\n      transform: translateY(-3px);\n      opacity: 0.9;\n    }\n  }\n</style>\n",
      "type": "registry:block",
      "target": "~/src/lib/components/blocks/chat-thread/ChatThread.svelte"
    }
  ],
  "dependencies": [
    "@lucide/svelte"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/svelte/avatar.json",
    "https://uipkge.dev/r/svelte/button.json",
    "https://uipkge.dev/r/svelte/textarea.json"
  ],
  "description": "Single-pane conversation surface. Peer header (avatar + online dot + call/video/more), grouped messages with day separators (Today/Yesterday/date), own vs. peer bubbles, read/delivered/sent ticks, typing indicator, composer with attach/emoji and Enter-to-send. Stateful demo; swap the `messages` state for your transport.",
  "categories": [
    "communication"
  ]
}