{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "chat-thread",
  "title": "Chat Thread",
  "type": "registry:block",
  "files": [
    {
      "path": "packages/registry-angular/blocks/chat-thread/chat-thread.component.ts",
      "content": "import {\n  Component,\n  DestroyRef,\n  ElementRef,\n  ViewChild,\n  afterRenderEffect,\n  computed,\n  inject,\n  signal,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport {\n  ArrowRight,\n  AtSign,\n  Check,\n  CheckCheck,\n  LucideAngularModule,\n  MoreVertical,\n  Paperclip,\n  Phone,\n  Smile,\n  Video,\n} from 'lucide-angular'\nimport { cn } from '@/lib/utils'\nimport { UiAvatarComponent, UiAvatarFallbackComponent, UiAvatarImageComponent } from '@/ui/avatar/avatar.component'\nimport { UiButtonComponent } from '@/ui/button/button.component'\nimport { UiTextareaComponent } from '@/ui/textarea/textarea.component'\n\nexport type ChatThreadMessageStatus = 'sent' | 'delivered' | 'read'\n\nexport interface ChatThreadMessage {\n  id: string\n  author: 'me' | 'them'\n  body: string\n  sentAt: Date\n  status?: ChatThreadMessageStatus\n}\n\nexport interface ChatThreadGroup {\n  label: string\n  items: ChatThreadMessage[]\n}\n\nconst now = new Date()\nconst yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)\n\nconst peer = {\n  name: 'Marcus Rivera',\n  handle: '@marcus',\n  avatarUrl: '',\n  initials: 'MR',\n  online: true,\n  lastSeen: 'Active now',\n}\n\nconst initialMessages: ChatThreadMessage[] = [\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\nfunction 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\nfunction formatTime(d: Date): string {\n  return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })\n}\n\n// The thin scrollbar + typing dots (React injects the same rules as a <style> tag: a vendor\n// pseudo-element and a keyframe, neither expressible as a utility). Longhand animation\n// properties so the per-dot `[animation-delay:…]` utilities (React's inline animationDelay)\n// are not reset by an unlayered `animation` shorthand.\nconst CHAT_THREAD_STYLES = `\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-name: typing-bounce;\n  animation-duration: 1s;\n  animation-iteration-count: infinite;\n  animation-timing-function: 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`\n\n/**\n * Single-pane conversation surface (Angular port of the React `ChatThread` block): peer\n * header, messages grouped by day, own vs. peer bubbles with read/delivered/sent ticks, a\n * typing indicator and a composer with Enter-to-send (Shift+Enter for a newline).\n * Stateful demo -- swap the `messages` signal for your transport.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-chat-thread, [ui-chat-thread]',\n  standalone: true,\n  // React renders the card div itself: keep this wrapper out of the parent's layout.\n  host: { class: 'contents' },\n  imports: [\n    LucideAngularModule,\n    UiAvatarComponent,\n    UiAvatarFallbackComponent,\n    UiAvatarImageComponent,\n    UiButtonComponent,\n    UiTextareaComponent,\n  ],\n  styles: [CHAT_THREAD_STYLES],\n  template: `\n    <div\n      data-slot=\"chat-thread\"\n      class=\"bg-card text-card-foreground flex h-[680px] 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            <ui-avatar class=\"size-9\">\n              @if (peer.avatarUrl) {\n                <ui-avatar-image [src]=\"peer.avatarUrl\" [alt]=\"peer.name\" />\n              }\n              <ui-avatar-fallback class=\"text-xs font-medium\">{{ peer.initials }}</ui-avatar-fallback>\n            </ui-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            }\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 ui-button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"Voice call\">\n            <lucide-icon [img]=\"Phone\" class=\"size-4\" />\n          </button>\n          <button ui-button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"Video call\">\n            <lucide-icon [img]=\"Video\" class=\"size-4\" />\n          </button>\n          <button ui-button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"More options\">\n            <lucide-icon [img]=\"MoreVertical\" class=\"size-4\" />\n          </button>\n        </div>\n      </header>\n\n      <div #scrollRoot class=\"chat-scroll flex-1 space-y-5 overflow-y-auto px-4 py-4\">\n        @for (group of groupedMessages(); track 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                >{{ group.label }}</span\n              >\n            </div>\n            @for (m of group.items; track m.id) {\n              <div [class]=\"rowClass(m)\">\n                @if (m.author === 'them') {\n                  <ui-avatar class=\"size-7\">\n                    <ui-avatar-fallback class=\"text-xs\">{{ peer.initials }}</ui-avatar-fallback>\n                  </ui-avatar>\n                }\n                <div [class]=\"columnClass(m)\">\n                  <div [class]=\"bubbleClass(m)\">{{ m.body }}</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                      <lucide-icon [img]=\"CheckCheck\" class=\"text-info size-3\" />\n                    }\n                    @if (m.author === 'me' && m.status === 'delivered') {\n                      <lucide-icon [img]=\"CheckCheck\" class=\"size-3\" />\n                    }\n                    @if (m.author === 'me' && m.status === 'sent') {\n                      <lucide-icon [img]=\"Check\" class=\"size-3\" />\n                    }\n                  </div>\n                </div>\n              </div>\n            }\n          </div>\n        }\n\n        @if (peerTyping()) {\n          <div class=\"flex items-end gap-2\">\n            <ui-avatar class=\"size-7\">\n              <ui-avatar-fallback class=\"text-xs\">{{ peer.initials }}</ui-avatar-fallback>\n            </ui-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 [animation-delay:0.15s]\"></span>\n              <span class=\"typing-dot [animation-delay:0.3s]\"></span>\n            </div>\n          </div>\n        }\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          <ui-textarea\n            [value]=\"draft()\"\n            (valueChange)=\"draft.set($event)\"\n            (keyDown)=\"onComposerKey($event)\"\n            [placeholder]=\"placeholder\"\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          />\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                ui-button\n                variant=\"ghost\"\n                size=\"icon\"\n                class=\"text-muted-foreground hover:text-foreground size-8\"\n                aria-label=\"Attach file\"\n              >\n                <lucide-icon [img]=\"Paperclip\" class=\"size-4\" />\n              </button>\n              <button\n                ui-button\n                variant=\"ghost\"\n                size=\"icon\"\n                class=\"text-muted-foreground hover:text-foreground size-8\"\n                aria-label=\"Emoji\"\n              >\n                <lucide-icon [img]=\"Smile\" class=\"size-4\" />\n              </button>\n              <button\n                ui-button\n                variant=\"ghost\"\n                size=\"icon\"\n                class=\"text-muted-foreground hover:text-foreground size-8\"\n                aria-label=\"Mention\"\n              >\n                <lucide-icon [img]=\"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                ui-button\n                class=\"h-8 gap-1.5 rounded-lg px-3 text-xs font-medium\"\n                [disabled]=\"!canSend()\"\n                aria-label=\"Send\"\n                (click)=\"send()\"\n              >\n                Send\n                <lucide-icon [img]=\"ArrowRight\" class=\"size-3.5\" />\n              </button>\n            </div>\n          </div>\n        </div>\n        <!-- One line per run of text: React's JSX has no space before the dot or after \"+\". -->\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> to send<span\n            class=\"mx-1.5 opacity-50\"\n            >·</span\n          ><kbd class=\"bg-muted/60 rounded px-1 py-0.5 font-mono text-xs\">Shift</kbd> +\n          <kbd class=\"bg-muted/60 rounded px-1 py-0.5 font-mono text-xs\">↵</kbd> for newline\n        </p>\n      </div>\n    </div>\n  `,\n})\nexport class UiChatThreadComponent {\n  protected readonly ArrowRight = ArrowRight\n  protected readonly AtSign = AtSign\n  protected readonly Check = Check\n  protected readonly CheckCheck = CheckCheck\n  protected readonly MoreVertical = MoreVertical\n  protected readonly Paperclip = Paperclip\n  protected readonly Phone = Phone\n  protected readonly Smile = Smile\n  protected readonly Video = Video\n\n  @ViewChild('scrollRoot', { static: true }) scrollRoot?: ElementRef<HTMLDivElement>\n\n  protected readonly peer = peer\n  protected readonly placeholder = `Message ${peer.name.split(' ')[0]}`\n\n  readonly messages = signal<ChatThreadMessage[]>(initialMessages)\n  readonly draft = signal('')\n  readonly peerTyping = signal(false)\n  readonly canSend = computed(() => this.draft().trim().length > 0)\n\n  readonly groupedMessages = computed(() => {\n    const groups: ChatThreadGroup[] = []\n    for (const m of this.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  private typingTimer: ReturnType<typeof setTimeout> | null = null\n\n  constructor() {\n    // React's effect on `messages`: keep the log scrolled to the newest message.\n    afterRenderEffect(() => {\n      this.messages()\n      this.scrollToBottom()\n    })\n    inject(DestroyRef).onDestroy(() => {\n      if (this.typingTimer) clearTimeout(this.typingTimer)\n    })\n  }\n\n  formatTime(d: Date): string {\n    return formatTime(d)\n  }\n\n  rowClass(m: ChatThreadMessage): string {\n    return cn('flex items-end gap-2', m.author === 'me' ? 'justify-end' : 'justify-start')\n  }\n\n  columnClass(m: ChatThreadMessage): string {\n    return cn('flex max-w-[78%] flex-col gap-1', m.author === 'me' ? 'items-end' : 'items-start')\n  }\n\n  bubbleClass(m: ChatThreadMessage): string {\n    return cn(\n      'rounded-2xl px-3.5 py-2 text-sm leading-relaxed shadow-sm',\n      m.author === 'me' ? 'bg-primary text-primary-foreground rounded-br-md' : 'bg-muted text-foreground rounded-bl-md',\n    )\n  }\n\n  private scrollToBottom(): void {\n    const el = this.scrollRoot?.nativeElement\n    if (!el) return\n    el.scrollTop = el.scrollHeight\n  }\n\n  send(): void {\n    const body = this.draft().trim()\n    if (!body) return\n    this.messages.update((prev) => [\n      ...prev,\n      { id: `m${Date.now()}`, author: 'me', body, sentAt: new Date(), status: 'sent' },\n    ])\n    this.draft.set('')\n    this.peerTyping.set(true)\n    if (this.typingTimer) clearTimeout(this.typingTimer)\n    this.typingTimer = setTimeout(() => {\n      this.peerTyping.set(false)\n      this.typingTimer = null\n    }, 1800)\n  }\n\n  /** Enter sends; Shift+Enter falls through to a newline. */\n  onComposerKey(e: KeyboardEvent): void {\n    if (e.key === 'Enter' && !e.shiftKey) {\n      e.preventDefault()\n      this.send()\n    }\n  }\n}\n",
      "type": "registry:block",
      "target": "~/src/app/components/blocks/chat-thread/chat-thread.component.ts"
    },
    {
      "path": "packages/registry-angular/blocks/chat-thread/index.ts",
      "content": "export {\n  UiChatThreadComponent,\n  type ChatThreadGroup,\n  type ChatThreadMessage,\n  type ChatThreadMessageStatus,\n} from './chat-thread.component'\n",
      "type": "registry:block",
      "target": "~/src/app/components/blocks/chat-thread/index.ts"
    }
  ],
  "dependencies": [
    "lucide-angular"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/angular/avatar.json",
    "https://uipkge.dev/r/angular/button.json",
    "https://uipkge.dev/r/angular/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` ref for your transport.",
  "categories": [
    "communication"
  ]
}