Menu

Change language

GitHub repo

Chat Thread

blockcommunication

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.

Installation

$npx uipkge-ng@latest add chat-thread
Manifest:https://uipkge.dev/r/angular/chat-thread.jsonInstalls to:src/app/components/blocks/chat-thread/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

ChatThreadMessage
interface ChatThreadMessage {
  id: string
  author: 'me' | 'them'
  body: string
  sentAt: Date
  status?: ChatThreadMessageStatus
}
ChatThreadGroup
interface ChatThreadGroup {
  label: string
  items: ChatThreadMessage[]
}

npm dependencies

Theming

CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.

--border

Files installed (2)

  • src/app/components/blocks/chat-thread/chat-thread.component.ts14.1 kB
    import {
      Component,
      DestroyRef,
      ElementRef,
      ViewChild,
      afterRenderEffect,
      computed,
      inject,
      signal,
      ChangeDetectionStrategy,
    } from '@angular/core'
    import {
      ArrowRight,
      AtSign,
      Check,
      CheckCheck,
      LucideAngularModule,
      MoreVertical,
      Paperclip,
      Phone,
      Smile,
      Video,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiAvatarComponent, UiAvatarFallbackComponent, UiAvatarImageComponent } from '@/ui/avatar/avatar.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiTextareaComponent } from '@/ui/textarea/textarea.component'
    
    export type ChatThreadMessageStatus = 'sent' | 'delivered' | 'read'
    
    export interface ChatThreadMessage {
      id: string
      author: 'me' | 'them'
      body: string
      sentAt: Date
      status?: ChatThreadMessageStatus
    }
    
    export interface ChatThreadGroup {
      label: string
      items: ChatThreadMessage[]
    }
    
    const now = new Date()
    const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
    
    const peer = {
      name: 'Marcus Rivera',
      handle: '@marcus',
      avatarUrl: '',
      initials: 'MR',
      online: true,
      lastSeen: 'Active now',
    }
    
    const initialMessages: ChatThreadMessage[] = [
      {
        id: 'm1',
        author: 'them',
        body: 'Hey — did you get a chance to look at the onboarding doc?',
        sentAt: new Date(yesterday.getTime() + 9 * 60 * 60 * 1000 + 14 * 60 * 1000),
      },
      {
        id: 'm2',
        author: 'me',
        body: 'Yeah, just finished. Two small notes on the time-off flow but otherwise looks solid.',
        sentAt: new Date(yesterday.getTime() + 9 * 60 * 60 * 1000 + 21 * 60 * 1000),
        status: 'read',
      },
      {
        id: 'm3',
        author: 'them',
        body: 'Perfect. Drop them in the doc and I will action this afternoon.',
        sentAt: new Date(yesterday.getTime() + 9 * 60 * 60 * 1000 + 23 * 60 * 1000),
      },
      {
        id: 'm4',
        author: 'me',
        body: 'Done. Also — are we still on for the dashboard review tomorrow at 10?',
        sentAt: new Date(now.getTime() - 42 * 60 * 1000),
        status: 'read',
      },
      {
        id: 'm5',
        author: 'them',
        body: 'Yes! I will bring the kanban deck. Want to push it to 10:30 so we can join from the standup?',
        sentAt: new Date(now.getTime() - 8 * 60 * 1000),
      },
    ]
    
    function dayLabel(d: Date): string {
      const sameDay = d.toDateString() === now.toDateString()
      if (sameDay) return 'Today'
      const sameYesterday = d.toDateString() === yesterday.toDateString()
      if (sameYesterday) return 'Yesterday'
      return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
    }
    
    function formatTime(d: Date): string {
      return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
    }
    
    // The thin scrollbar + typing dots (React injects the same rules as a <style> tag: a vendor
    // pseudo-element and a keyframe, neither expressible as a utility). Longhand animation
    // properties so the per-dot `[animation-delay:…]` utilities (React's inline animationDelay)
    // are not reset by an unlayered `animation` shorthand.
    const CHAT_THREAD_STYLES = `
    .chat-scroll::-webkit-scrollbar {
      width: 6px;
    }
    .chat-scroll::-webkit-scrollbar-thumb {
      background: var(--border);
      border-radius: 3px;
    }
    .typing-dot {
      display: inline-block;
      width: 6px;
      height: 6px;
      border-radius: 9999px;
      background: currentColor;
      opacity: 0.5;
      animation-name: typing-bounce;
      animation-duration: 1s;
      animation-iteration-count: infinite;
      animation-timing-function: ease-in-out;
    }
    @keyframes typing-bounce {
      0%,
      80%,
      100% {
        transform: translateY(0);
        opacity: 0.35;
      }
      40% {
        transform: translateY(-3px);
        opacity: 0.9;
      }
    }
    `
    
    /**
     * Single-pane conversation surface (Angular port of the React `ChatThread` block): peer
     * header, messages grouped by day, own vs. peer bubbles with read/delivered/sent ticks, a
     * typing indicator and a composer with Enter-to-send (Shift+Enter for a newline).
     * Stateful demo -- swap the `messages` signal for your transport.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-chat-thread, [ui-chat-thread]',
      standalone: true,
      // React renders the card div itself: keep this wrapper out of the parent's layout.
      host: { class: 'contents' },
      imports: [
        LucideAngularModule,
        UiAvatarComponent,
        UiAvatarFallbackComponent,
        UiAvatarImageComponent,
        UiButtonComponent,
        UiTextareaComponent,
      ],
      styles: [CHAT_THREAD_STYLES],
      template: `
        <div
          data-slot="chat-thread"
          class="bg-card text-card-foreground flex h-[680px] w-full flex-col overflow-hidden rounded-xl border shadow-sm"
        >
          <header class="flex h-14 shrink-0 items-center justify-between gap-3 border-b px-4">
            <div class="flex min-w-0 items-center gap-3">
              <div class="relative">
                <ui-avatar class="size-9">
                  @if (peer.avatarUrl) {
                    <ui-avatar-image [src]="peer.avatarUrl" [alt]="peer.name" />
                  }
                  <ui-avatar-fallback class="text-xs font-medium">{{ peer.initials }}</ui-avatar-fallback>
                </ui-avatar>
                @if (peer.online) {
                  <span class="bg-success ring-card absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2"></span>
                }
              </div>
              <div class="min-w-0">
                <p class="truncate text-sm font-semibold tracking-tight">{{ peer.name }}</p>
                <p class="text-muted-foreground truncate text-xs">{{ peer.lastSeen }}</p>
              </div>
            </div>
            <div class="flex items-center gap-1">
              <button ui-button variant="ghost" size="icon" class="size-8" aria-label="Voice call">
                <lucide-icon [img]="Phone" class="size-4" />
              </button>
              <button ui-button variant="ghost" size="icon" class="size-8" aria-label="Video call">
                <lucide-icon [img]="Video" class="size-4" />
              </button>
              <button ui-button variant="ghost" size="icon" class="size-8" aria-label="More options">
                <lucide-icon [img]="MoreVertical" class="size-4" />
              </button>
            </div>
          </header>
    
          <div #scrollRoot class="chat-scroll flex-1 space-y-5 overflow-y-auto px-4 py-4">
            @for (group of groupedMessages(); track group.label) {
              <div class="space-y-3">
                <div class="flex items-center justify-center">
                  <span
                    class="text-muted-foreground bg-muted/60 rounded-full px-2.5 py-0.5 text-xs font-medium tracking-wide uppercase"
                    >{{ group.label }}</span
                  >
                </div>
                @for (m of group.items; track m.id) {
                  <div [class]="rowClass(m)">
                    @if (m.author === 'them') {
                      <ui-avatar class="size-7">
                        <ui-avatar-fallback class="text-xs">{{ peer.initials }}</ui-avatar-fallback>
                      </ui-avatar>
                    }
                    <div [class]="columnClass(m)">
                      <div [class]="bubbleClass(m)">{{ m.body }}</div>
                      <div class="text-muted-foreground flex items-center gap-1 px-1 text-xs tabular-nums">
                        <span>{{ formatTime(m.sentAt) }}</span>
                        @if (m.author === 'me' && m.status === 'read') {
                          <lucide-icon [img]="CheckCheck" class="text-info size-3" />
                        }
                        @if (m.author === 'me' && m.status === 'delivered') {
                          <lucide-icon [img]="CheckCheck" class="size-3" />
                        }
                        @if (m.author === 'me' && m.status === 'sent') {
                          <lucide-icon [img]="Check" class="size-3" />
                        }
                      </div>
                    </div>
                  </div>
                }
              </div>
            }
    
            @if (peerTyping()) {
              <div class="flex items-end gap-2">
                <ui-avatar class="size-7">
                  <ui-avatar-fallback class="text-xs">{{ peer.initials }}</ui-avatar-fallback>
                </ui-avatar>
                <div class="bg-muted text-muted-foreground flex items-center gap-1 rounded-2xl rounded-bl-md px-3 py-2.5">
                  <span class="typing-dot"></span>
                  <span class="typing-dot [animation-delay:0.15s]"></span>
                  <span class="typing-dot [animation-delay:0.3s]"></span>
                </div>
              </div>
            }
          </div>
    
          <div class="bg-muted/20 border-t px-4 py-3">
            <div
              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"
            >
              <ui-textarea
                [value]="draft()"
                (valueChange)="draft.set($event)"
                (keyDown)="onComposerKey($event)"
                [placeholder]="placeholder"
                [rows]="1"
                variant="plain"
                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"
              />
              <div class="flex items-center justify-between gap-2 px-2 pb-2">
                <div class="flex items-center gap-0.5">
                  <button
                    ui-button
                    variant="ghost"
                    size="icon"
                    class="text-muted-foreground hover:text-foreground size-8"
                    aria-label="Attach file"
                  >
                    <lucide-icon [img]="Paperclip" class="size-4" />
                  </button>
                  <button
                    ui-button
                    variant="ghost"
                    size="icon"
                    class="text-muted-foreground hover:text-foreground size-8"
                    aria-label="Emoji"
                  >
                    <lucide-icon [img]="Smile" class="size-4" />
                  </button>
                  <button
                    ui-button
                    variant="ghost"
                    size="icon"
                    class="text-muted-foreground hover:text-foreground size-8"
                    aria-label="Mention"
                  >
                    <lucide-icon [img]="AtSign" class="size-4" />
                  </button>
                </div>
                <div class="flex items-center gap-2">
                  <kbd
                    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"
                  >
                    <span>↵</span>
                  </kbd>
                  <button
                    ui-button
                    class="h-8 gap-1.5 rounded-lg px-3 text-xs font-medium"
                    [disabled]="!canSend()"
                    aria-label="Send"
                    (click)="send()"
                  >
                    Send
                    <lucide-icon [img]="ArrowRight" class="size-3.5" />
                  </button>
                </div>
              </div>
            </div>
            <!-- One line per run of text: React's JSX has no space before the dot or after "+". -->
            <p class="text-muted-foreground/70 mt-2 text-center text-xs">
              <kbd class="bg-muted/60 rounded px-1 py-0.5 font-mono text-xs">Enter</kbd> to send<span
                class="mx-1.5 opacity-50"
                >·</span
              ><kbd class="bg-muted/60 rounded px-1 py-0.5 font-mono text-xs">Shift</kbd> +
              <kbd class="bg-muted/60 rounded px-1 py-0.5 font-mono text-xs">↵</kbd> for newline
            </p>
          </div>
        </div>
      `,
    })
    export class UiChatThreadComponent {
      protected readonly ArrowRight = ArrowRight
      protected readonly AtSign = AtSign
      protected readonly Check = Check
      protected readonly CheckCheck = CheckCheck
      protected readonly MoreVertical = MoreVertical
      protected readonly Paperclip = Paperclip
      protected readonly Phone = Phone
      protected readonly Smile = Smile
      protected readonly Video = Video
    
      @ViewChild('scrollRoot', { static: true }) scrollRoot?: ElementRef<HTMLDivElement>
    
      protected readonly peer = peer
      protected readonly placeholder = `Message ${peer.name.split(' ')[0]}`
    
      readonly messages = signal<ChatThreadMessage[]>(initialMessages)
      readonly draft = signal('')
      readonly peerTyping = signal(false)
      readonly canSend = computed(() => this.draft().trim().length > 0)
    
      readonly groupedMessages = computed(() => {
        const groups: ChatThreadGroup[] = []
        for (const m of this.messages()) {
          const label = dayLabel(m.sentAt)
          const last = groups[groups.length - 1]
          if (last && last.label === label) last.items.push(m)
          else groups.push({ label, items: [m] })
        }
        return groups
      })
    
      private typingTimer: ReturnType<typeof setTimeout> | null = null
    
      constructor() {
        // React's effect on `messages`: keep the log scrolled to the newest message.
        afterRenderEffect(() => {
          this.messages()
          this.scrollToBottom()
        })
        inject(DestroyRef).onDestroy(() => {
          if (this.typingTimer) clearTimeout(this.typingTimer)
        })
      }
    
      formatTime(d: Date): string {
        return formatTime(d)
      }
    
      rowClass(m: ChatThreadMessage): string {
        return cn('flex items-end gap-2', m.author === 'me' ? 'justify-end' : 'justify-start')
      }
    
      columnClass(m: ChatThreadMessage): string {
        return cn('flex max-w-[78%] flex-col gap-1', m.author === 'me' ? 'items-end' : 'items-start')
      }
    
      bubbleClass(m: ChatThreadMessage): string {
        return cn(
          'rounded-2xl px-3.5 py-2 text-sm leading-relaxed shadow-sm',
          m.author === 'me' ? 'bg-primary text-primary-foreground rounded-br-md' : 'bg-muted text-foreground rounded-bl-md',
        )
      }
    
      private scrollToBottom(): void {
        const el = this.scrollRoot?.nativeElement
        if (!el) return
        el.scrollTop = el.scrollHeight
      }
    
      send(): void {
        const body = this.draft().trim()
        if (!body) return
        this.messages.update((prev) => [
          ...prev,
          { id: `m${Date.now()}`, author: 'me', body, sentAt: new Date(), status: 'sent' },
        ])
        this.draft.set('')
        this.peerTyping.set(true)
        if (this.typingTimer) clearTimeout(this.typingTimer)
        this.typingTimer = setTimeout(() => {
          this.peerTyping.set(false)
          this.typingTimer = null
        }, 1800)
      }
    
      /** Enter sends; Shift+Enter falls through to a newline. */
      onComposerKey(e: KeyboardEvent): void {
        if (e.key === 'Enter' && !e.shiftKey) {
          e.preventDefault()
          this.send()
        }
      }
    }
    
  • src/app/components/blocks/chat-thread/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/chat-thread.json