Chat Thread
blockcommunicationSingle-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
$pnpm dlx uipkge-ng@latest add chat-thread$npx uipkge-ng@latest add chat-thread$yarn dlx uipkge-ng@latest add chat-thread$bunx uipkge-ng@latest add chat-threadManifest:
https://uipkge.dev/r/angular/chat-thread.jsonInstalls to:src/app/components/blocks/chat-thread/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
ChatThreadMessageinterface ChatThreadMessage {
id: string
author: 'me' | 'them'
body: string
sentAt: Date
status?: ChatThreadMessageStatus
}ChatThreadGroupinterface 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.
--borderFiles 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
export { UiChatThreadComponent, type ChatThreadGroup, type ChatThreadMessage, type ChatThreadMessageStatus, } from './chat-thread.component'
Raw manifest:https://uipkge.dev/r/angular/chat-thread.json