Inbox
blockcommunicationThree-pane mail surface. Left rail: Compose button, folders (Inbox/Starred/Sent/Drafts/Spam/Trash) with unread counts, colour-coded labels with totals. Middle: searchable message list with sender, subject, preview, labels, attachment glyphs, star toggle, smart timestamps. Right: reader pane with subject, from/to, body, attachment chip, reply/forward/archive actions. Stateful demo — swap `mails` for your data source.
Installation
$pnpm dlx uipkge-ng@latest add inbox$npx uipkge-ng@latest add inbox$yarn dlx uipkge-ng@latest add inbox$bunx uipkge-ng@latest add inboxManifest:
https://uipkge.dev/r/angular/inbox.jsonInstalls to:src/app/components/blocks/inbox/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.
Folderinterface Folder {
id: FolderId
label: string
icon: LucideIconData
}LabelTaginterface LabelTag {
id: string
label: string
color: string
}Mailinterface Mail {
id: string
folder: FolderId
from: { name: string; email: string; initials: string }
to: string
subject: string
preview: string
body: string
receivedAt: Date
read: boolean
starred: boolean
hasAttachment: boolean
labels: string[]
}npm dependencies
Files installed (2)
src/app/components/blocks/inbox/inbox.component.ts30.2 kB
// Three-pane mail surface. The sample `messages` below are a stub -- pass your own list through the // `messages` input (or edit `defaultMails`) and wire archive / send to your mail API. import { Component, Input, computed, signal, ChangeDetectionStrategy } from '@angular/core' import { AlertOctagon, Archive, ArrowLeft, ArrowRight, AtSign, Bookmark, FileText, Forward, Inbox as InboxIcon, LucideAngularModule, MoreVertical, Paperclip, Pencil, Reply, ReplyAll, Search, Send, Smile, Star, Tag, Trash2, type LucideIconData, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiAvatarComponent, UiAvatarFallbackComponent } from '@/ui/avatar/avatar.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiInputComponent } from '@/ui/input/input.component' export type ReplyMode = 'reply' | 'reply-all' | 'forward' export type FolderId = 'inbox' | 'starred' | 'sent' | 'drafts' | 'spam' | 'trash' interface Folder { id: FolderId label: string icon: LucideIconData } export interface LabelTag { id: string label: string color: string } export interface Mail { id: string folder: FolderId from: { name: string; email: string; initials: string } to: string subject: string preview: string body: string receivedAt: Date read: boolean starred: boolean hasAttachment: boolean labels: string[] } const folders: Folder[] = [ { id: 'inbox', label: 'Inbox', icon: InboxIcon }, { id: 'starred', label: 'Starred', icon: Star }, { id: 'sent', label: 'Sent', icon: Send }, { id: 'drafts', label: 'Drafts', icon: FileText }, { id: 'spam', label: 'Spam', icon: AlertOctagon }, { id: 'trash', label: 'Trash', icon: Trash2 }, ] const labels: LabelTag[] = [ { id: 'work', label: 'Work', color: 'bg-primary' }, { id: 'personal', label: 'Personal', color: 'bg-success' }, { id: 'finance', label: 'Finance', color: 'bg-info' }, { id: 'travel', label: 'Travel', color: 'bg-warning' }, ] const now = new Date() const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000) const defaultMails: Mail[] = [ { id: 'e1', folder: 'inbox', from: { name: 'Sarah Connor', email: '[email protected]', initials: 'SC' }, to: '[email protected]', subject: 'Q2 OKR draft — your sign-off needed', preview: 'Pulled together the Q2 OKR draft from the leadership offsite. I have flagged the two areas where we need…', body: `Hi,\n\nPulled together the Q2 OKR draft from the leadership offsite. I have flagged the two areas where we need your call before we lock it:\n\n• Hiring plan: 4 vs 6 ENG roles (page 3)\n• EU rollout timing: April vs June (page 7)\n\nGoing to share with the rest of the leadership team Wednesday EOD, so anything from you by Tuesday is great.\n\nThanks,\nSarah`, receivedAt: new Date(now.getTime() - 18 * 60 * 1000), read: false, starred: true, hasAttachment: true, labels: ['work'], }, { id: 'e2', folder: 'inbox', from: { name: 'Marcus Rivera', email: '[email protected]', initials: 'MR' }, to: '[email protected]', subject: 'Re: Dashboard review tomorrow', preview: 'Yes! Pushing to 10:30 so we can join from the standup. I will own the slides and pull the latest kanban metrics.', body: `Yes! Pushing to 10:30 so we can join from the standup. I will own the slides and pull the latest kanban metrics.\n\nFew things I want to cover:\n - Throughput delta vs last sprint\n - The two stories we de-scoped\n - Q2 capacity ask\n\nSee you tomorrow,\nMarcus`, receivedAt: new Date(now.getTime() - 95 * 60 * 1000), read: false, starred: false, hasAttachment: false, labels: ['work'], }, { id: 'e3', folder: 'inbox', from: { name: 'Stripe', email: '[email protected]', initials: 'St' }, to: '[email protected]', subject: 'Your invoice from Acme Inc. — $1,290.00', preview: 'Thanks for using Stripe. Your invoice for the period Mar 1 – Mar 31 is attached. Total due: $1,290.00…', body: 'Thanks for using Stripe. Your invoice for the period Mar 1 – Mar 31 is attached. Total due: $1,290.00.', receivedAt: new Date(now.getTime() - 4 * 60 * 60 * 1000), read: true, starred: false, hasAttachment: true, labels: ['finance'], }, { id: 'e4', folder: 'inbox', from: { name: 'Linear', email: '[email protected]', initials: 'Li' }, to: '[email protected]', subject: 'Weekly summary: 14 issues closed, 22 opened', preview: 'Good news — the team closed 14 issues this week, up 3 from last week. Top contributors: Marcus, Priya, Devon.', body: 'Good news — the team closed 14 issues this week, up 3 from last week. Top contributors: Marcus, Priya, Devon.', receivedAt: new Date(now.getTime() - 9 * 60 * 60 * 1000), read: true, starred: false, hasAttachment: false, labels: ['work'], }, { id: 'e5', folder: 'inbox', from: { name: 'Priya Shah', email: '[email protected]', initials: 'PS' }, to: '[email protected]', subject: 'Color tokens are live in staging', preview: 'Pushed the new OKLCH token set to staging. All chart colors, success/warning/info, sidebar variants. Take a look…', body: 'Pushed the new OKLCH token set to staging. All chart colors, success/warning/info, sidebar variants. Take a look when you have a sec.', receivedAt: yesterday, read: true, starred: true, hasAttachment: false, labels: ['work'], }, { id: 'e6', folder: 'inbox', from: { name: 'United Airlines', email: '[email protected]', initials: 'UA' }, to: '[email protected]', subject: 'Your itinerary: SFO → LHR, departing Apr 12', preview: 'Your reservation is confirmed. Check-in opens 24h before departure. Booking reference: 7HQ4P2.', body: 'Your reservation is confirmed. Check-in opens 24h before departure. Booking reference: 7HQ4P2.', receivedAt: new Date(now.getTime() - 38 * 60 * 60 * 1000), read: true, starred: false, hasAttachment: true, labels: ['travel'], }, { id: 'e7', folder: 'inbox', from: { name: 'Mom', email: '[email protected]', initials: 'M' }, to: '[email protected]', subject: 'Sunday lunch?', preview: 'Are you free for lunch this Sunday? Dad is making his lasagne. Let me know!', body: 'Are you free for lunch this Sunday? Dad is making his lasagne. Let me know!', receivedAt: new Date(now.getTime() - 55 * 60 * 60 * 1000), read: true, starred: false, hasAttachment: false, labels: ['personal'], }, ] function byNewest(a: Mail, b: Mail): number { return b.receivedAt.getTime() - a.receivedAt.getTime() } function formatMailTime(d: Date): string { if (d >= todayStart) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) if (d.toDateString() === yesterday.toDateString()) return 'Yesterday' const diffDays = Math.floor((now.getTime() - d.getTime()) / (24 * 60 * 60 * 1000)) if (diffDays < 7) return d.toLocaleDateString(undefined, { weekday: 'short' }) return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) } function formatFullTime(d: Date): string { return d.toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', }) } function labelMeta(id: string): LabelTag | undefined { return labels.find((l) => l.id === id) } const replyTabBase = 'inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition-colors' const replyTabActive = 'bg-background text-foreground border-input/60 border shadow-sm' const replyTabIdle = 'text-muted-foreground hover:bg-background/60 hover:text-foreground' const railRow = 'flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors' const railActive = 'bg-primary/10 text-primary font-medium' const railIdle = 'hover:bg-muted/60 text-foreground/80' /** * Inbox -- the React block, 1:1. Left rail: Compose, folders with unread counts and colour-coded * labels with totals. Middle: searchable message list. Right: reader pane with a reply composer * (Cmd/Ctrl+Enter sends). State is local; `messages` seeds it, like React's `useState(messages)`. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-inbox, [ui-inbox]', standalone: true, imports: [LucideAngularModule, UiAvatarComponent, UiAvatarFallbackComponent, UiButtonComponent, UiInputComponent], template: ` <div data-slot="inbox" class="bg-card text-card-foreground grid h-[680px] w-full grid-cols-[220px_340px_1fr] overflow-hidden rounded-xl border shadow-sm" > <aside class="bg-muted/30 flex min-w-0 flex-col border-r"> <div class="flex h-14 shrink-0 items-center border-b px-3"> <button ui-button class="h-9 w-full justify-start gap-2 rounded-lg" size="sm"> <lucide-icon [img]="Pencil" class="size-4" /> Compose </button> </div> <nav class="flex-1 space-y-1 overflow-y-auto px-3 pt-3 pb-3"> <p class="text-muted-foreground px-2 pb-1 text-xs font-semibold tracking-widest uppercase">Folders</p> @for (f of folders; track f.id) { <button [class]="folderClass(f.id)" (click)="selectFolder(f.id)"> <span class="flex min-w-0 items-center gap-2"> <lucide-icon [img]="f.icon" class="size-4 shrink-0" /> <span class="truncate">{{ f.label }}</span> </span> @if (unreadCount(f.id) > 0) { <span class="text-muted-foreground shrink-0 text-xs tabular-nums">{{ unreadCount(f.id) }}</span> } </button> } <p class="text-muted-foreground mt-4 px-2 pb-1 text-xs font-semibold tracking-widest uppercase">Labels</p> @for (l of labels; track l.id) { <button [class]="labelClass(l.id)" (click)="selectLabel(l.id)"> <span class="flex min-w-0 items-center gap-2"> <span [class]="labelDotClass(l.color)"></span> <span class="truncate">{{ l.label }}</span> </span> <span class="text-muted-foreground shrink-0 text-xs tabular-nums">{{ labelCount(l.id) }}</span> </button> } </nav> </aside> <section class="bg-background flex min-w-0 flex-col border-r"> <div class="flex h-14 shrink-0 items-center gap-2 border-b px-3"> <div class="relative flex-1"> <!-- [class], not class: lucide-angular copies a static class onto its host too, which would offset the svg twice. --> <lucide-icon [img]="Search" [class]="'text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2'" /> <ui-input [value]="search()" (valueChange)="search.set($event)" placeholder="Search mail" class="h-8 rounded-lg pl-8 text-xs" /> </div> <button ui-button variant="ghost" size="icon" class="size-8" aria-label="Filter"> <lucide-icon [img]="Tag" class="size-4" /> </button> </div> <div class="flex items-center justify-between px-4 pt-3 pb-2"> <h2 class="text-sm font-semibold tracking-tight capitalize">{{ listTitle() }}</h2> <span class="text-muted-foreground text-xs tabular-nums"> {{ visibleMails().length }} message{{ visibleMails().length === 1 ? '' : 's' }} </span> </div> <div class="list-scroll flex-1 overflow-y-auto pb-3"> @for (m of visibleMails(); track m.id) { <button aria-label="Attach file" [class]="rowClass(m.id)" (click)="selectMail(m.id)"> @if (m.id === activeId()) { <span class="bg-primary absolute top-2 bottom-2 left-0 w-[3px] rounded-r-full"></span> } <div class="flex items-start gap-3"> <ui-avatar class="mt-0.5 size-8 shrink-0"> <ui-avatar-fallback class="text-xs">{{ m.from.initials }}</ui-avatar-fallback> </ui-avatar> <div class="min-w-0 flex-1"> <div class="flex items-center justify-between gap-2"> <p [class]="senderClass(m.read)">{{ m.from.name }}</p> <span class="text-muted-foreground shrink-0 text-xs tabular-nums"> {{ mailTime(m.receivedAt) }} </span> </div> <p [class]="subjectClass(m.read)">{{ m.subject }}</p> <p class="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">{{ m.preview }}</p> <div class="mt-1.5 flex items-center gap-1.5"> @for (lid of m.labels; track $index) { <span class="text-muted-foreground bg-muted inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-xs" > <span [class]="chipDotClass(lid)"></span> {{ labelName(lid) }} </span> } @if (m.hasAttachment) { <lucide-icon [img]="Paperclip" class="text-muted-foreground size-3" /> } @if (!m.read) { <span class="bg-primary ml-auto size-1.5 shrink-0 rounded-full"></span> } </div> </div> <span role="button" tabindex="0" class="text-muted-foreground hover:text-warning inline-flex shrink-0 cursor-pointer items-center justify-center transition-colors" [attr.title]="m.starred ? 'Unstar' : 'Star'" (click)="toggleStar(m.id, $event)" (keydown)="onStarKey(m.id, $event)" > <lucide-icon [img]="Star" [class]="starClass(m.starred, 'size-3.5')" /> </span> </div> </button> } @if (visibleMails().length === 0) { <div class="flex flex-col items-center justify-center px-6 py-16 text-center"> <div class="bg-muted mb-3 rounded-full p-3"> <lucide-icon [img]="InboxIcon" class="text-muted-foreground size-5" /> </div> <p class="text-sm font-medium">Nothing here</p> <p class="text-muted-foreground mt-0.5 text-xs">No messages match the current filter.</p> </div> } </div> </section> <section class="bg-background flex min-w-0 flex-col"> @if (activeMail(); as mail) { <div class="flex h-14 shrink-0 items-center justify-between gap-2 border-b px-4"> <div class="flex items-center gap-1"> <button ui-button variant="ghost" size="icon" class="size-8" aria-label="Back"> <lucide-icon [img]="ArrowLeft" class="size-4" /> </button> <button ui-button variant="ghost" size="icon" class="size-8" aria-label="Archive" (click)="archive(mail.id)" > <lucide-icon [img]="Archive" class="size-4" /> </button> <button ui-button variant="ghost" size="icon" class="size-8" aria-label="Delete"> <lucide-icon [img]="Trash2" class="size-4" /> </button> <button ui-button variant="ghost" size="icon" class="size-8" aria-label="Save"> <lucide-icon [img]="Bookmark" class="size-4" /> </button> </div> <div class="flex items-center gap-1"> <button ui-button variant="ghost" size="icon" class="size-8" aria-label="Reply"> <lucide-icon [img]="Reply" class="size-4" /> </button> <button ui-button variant="ghost" size="icon" class="size-8" aria-label="Reply all"> <lucide-icon [img]="ReplyAll" class="size-4" /> </button> <button ui-button variant="ghost" size="icon" class="size-8" aria-label="Forward"> <lucide-icon [img]="Forward" 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> </div> <div class="reader-scroll flex-1 overflow-y-auto"> <div class="px-6 pt-5 pb-3"> <h1 class="text-foreground text-xl leading-tight font-semibold tracking-tight">{{ mail.subject }}</h1> <div class="mt-2 flex flex-wrap items-center gap-2"> @for (lid of mail.labels; track $index) { <span class="text-foreground/70 bg-muted inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs" > <span [class]="chipDotClass(lid)"></span> {{ labelName(lid) }} </span> } </div> </div> <div class="flex items-start gap-3 px-6 pb-4"> <ui-avatar class="size-10"> <ui-avatar-fallback>{{ mail.from.initials }}</ui-avatar-fallback> </ui-avatar> <div class="min-w-0 flex-1"> <div class="flex flex-wrap items-baseline gap-x-2"> <p class="text-sm font-semibold">{{ mail.from.name }}</p> <p class="text-muted-foreground text-xs"><{{ mail.from.email }}></p> </div> <p class="text-muted-foreground text-xs">to {{ mail.to }}</p> </div> <div class="text-muted-foreground flex shrink-0 items-center gap-2 text-xs"> <span>{{ fullTime(mail.receivedAt) }}</span> <button class="hover:text-warning transition-colors" [attr.title]="mail.starred ? 'Unstar' : 'Star'" (click)="toggleStar(mail.id)" > <lucide-icon [img]="Star" [class]="starClass(mail.starred, 'size-4')" /> </button> </div> </div> <article class="text-foreground/90 px-6 pb-6 text-sm leading-relaxed whitespace-pre-wrap"> {{ mail.body }} </article> @if (mail.hasAttachment) { <div class="px-6 pb-6"> <div class="bg-muted/40 flex items-center gap-3 rounded-lg border px-3 py-2.5"> <div class="bg-info/10 text-info flex size-8 items-center justify-center rounded-md"> <lucide-icon [img]="FileText" class="size-4" /> </div> <div class="min-w-0 flex-1"> <p class="truncate text-xs font-medium">attachment.pdf</p> <p class="text-muted-foreground text-xs">142 KB · PDF</p> </div> <button ui-button aria-label="Download attachment" variant="ghost" size="sm" class="h-7 text-xs"> Download </button> </div> </div> } </div> <div class="bg-muted/20 border-t px-4 pt-3 pb-3"> <div class="mb-2 flex items-center gap-1"> <button [class]="replyTabClass('reply')" (click)="changeReplyMode('reply')"> <lucide-icon [img]="Reply" class="size-3.5" /> Reply </button> <button [class]="replyTabClass('reply-all')" (click)="changeReplyMode('reply-all')"> <lucide-icon [img]="ReplyAll" class="size-3.5" /> Reply all </button> <button [class]="replyTabClass('forward')" (click)="changeReplyMode('forward')"> <lucide-icon [img]="Forward" class="size-3.5" /> Forward </button> </div> <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" > @if (replyRecipient()) { <p class="border-input/40 text-muted-foreground border-b px-4 pt-2.5 pb-2 text-xs"> <span class="text-foreground/70 font-medium">To:</span> {{ replyRecipient() }} </p> } <textarea [value]="replyDraft()" (input)="onDraftInput($event)" [attr.placeholder]="replyPlaceholder()" rows="1" 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 outline-none focus-visible:ring-0" (keydown)="onReplyKey($event)" ></textarea> <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><span>↵</span></kbd > <button ui-button class="h-8 gap-1.5 rounded-lg px-3 text-xs font-medium" [disabled]="sendDisabled()" aria-label="Send" (click)="sendReply()" > Send <lucide-icon [img]="ArrowRight" class="size-3.5" /> </button> </div> </div> </div> @if (justSent()) { <p class="text-success mt-2 text-center text-xs font-medium">Reply sent</p> } </div> } @else { <div class="flex flex-1 items-center justify-center"> <div class="text-center"> <div class="bg-muted mx-auto mb-3 size-12 rounded-full p-3"> <lucide-icon [img]="InboxIcon" class="text-muted-foreground size-6" /> </div> <p class="text-sm font-medium">No message selected</p> <p class="text-muted-foreground mt-1 text-xs">Pick a message from the list to read it here.</p> </div> </div> } </section> </div> `, }) export class UiInboxComponent { /** Seed list (React `messages`, default: the stubbed sample mails). */ @Input() set messages(value: Mail[] | null | undefined) { this.mails.set(value ?? defaultMails) } protected readonly Pencil = Pencil protected readonly Search = Search protected readonly Tag = Tag protected readonly Paperclip = Paperclip protected readonly Star = Star protected readonly InboxIcon = InboxIcon protected readonly ArrowLeft = ArrowLeft protected readonly Archive = Archive protected readonly Trash2 = Trash2 protected readonly Bookmark = Bookmark protected readonly Reply = Reply protected readonly ReplyAll = ReplyAll protected readonly Forward = Forward protected readonly MoreVertical = MoreVertical protected readonly FileText = FileText protected readonly Smile = Smile protected readonly AtSign = AtSign protected readonly ArrowRight = ArrowRight protected readonly folders = folders protected readonly labels = labels readonly mails = signal<Mail[]>(defaultMails) readonly activeFolder = signal<FolderId>('inbox') readonly activeLabel = signal<string | null>(null) readonly activeId = signal<string>('e1') readonly search = signal('') readonly replyMode = signal<ReplyMode>('reply') readonly replyDraft = signal('') readonly justSent = signal(false) private readonly labelCounts = computed(() => labels.reduce<Record<string, number>>((acc, l) => { acc[l.id] = this.mails().filter((m) => m.labels.includes(l.id)).length return acc }, {}), ) readonly visibleMails = computed(() => { const q = this.search().trim().toLowerCase() let list = this.inView(this.mails()) if (q) { list = list.filter( (m) => m.subject.toLowerCase().includes(q) || m.from.name.toLowerCase().includes(q) || m.preview.toLowerCase().includes(q), ) } return [...list].sort(byNewest) }) readonly activeMail = computed(() => this.mails().find((m) => m.id === this.activeId()) ?? null) readonly replyRecipient = computed(() => { const m = this.activeMail() if (!m) return '' if (this.replyMode() === 'forward') return '' return m.from.name + ' <' + m.from.email + '>' }) readonly replyPlaceholder = computed(() => this.replyMode() === 'forward' ? 'Add a note before forwarding' : 'Write a reply', ) readonly sendDisabled = computed(() => !this.replyDraft().trim()) /** Mails in the current folder / label, ignoring the search (React's archive + select logic). */ private inView(list: Mail[]): Mail[] { const label = this.activeLabel() const folder = this.activeFolder() return list.filter((m) => { if (label) return m.labels.includes(label) if (folder === 'starred') return m.starred && m.folder !== 'trash' return m.folder === folder }) } /** React's `useEffect(..., [activeId])`: a new message resets the composer. */ private setActive(id: string): void { if (id === this.activeId()) return this.activeId.set(id) this.replyDraft.set('') this.replyMode.set('reply') this.justSent.set(false) } unreadCount(id: FolderId): number { const mails = this.mails() if (id === 'starred') return mails.filter((m) => m.starred && !m.read && m.folder !== 'trash').length return mails.filter((m) => m.folder === id && !m.read).length } labelCount(id: string): number { return this.labelCounts()[id] ?? 0 } labelName(id: string): string | undefined { return labelMeta(id)?.label } listTitle(): string | undefined { const label = this.activeLabel() return label ? labelMeta(label)?.label : this.activeFolder() } mailTime(d: Date): string { return formatMailTime(d) } fullTime(d: Date): string { return formatFullTime(d) } selectMail(id: string): void { this.setActive(id) this.mails.update((prev) => prev.map((m) => (m.id === id ? { ...m, read: true } : m))) } selectFolder(id: FolderId): void { this.activeFolder.set(id) this.activeLabel.set(null) const first = this.mails() .filter((m) => (id === 'starred' ? m.starred && m.folder !== 'trash' : m.folder === id)) .sort(byNewest)[0] if (first) this.setActive(first.id) } selectLabel(id: string): void { this.activeLabel.set(id) const first = this.mails() .filter((m) => m.labels.includes(id)) .sort(byNewest)[0] if (first) this.setActive(first.id) } toggleStar(id: string, e?: Event): void { e?.stopPropagation() this.mails.update((prev) => prev.map((m) => (m.id === id ? { ...m, starred: !m.starred } : m))) } onStarKey(id: string, e: KeyboardEvent): void { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() this.toggleStar(id, e) } } archive(id: string): void { const next = this.mails().filter((m) => m.id !== id) this.mails.set(next) const nextActive = [...this.inView(next)].sort(byNewest)[0] if (nextActive) this.setActive(nextActive.id) } changeReplyMode(mode: ReplyMode): void { this.replyMode.set(mode) this.justSent.set(false) } onDraftInput(e: Event): void { this.replyDraft.set((e.target as HTMLTextAreaElement).value) } sendReply(): void { const body = this.replyDraft().trim() if (!body) return this.replyDraft.set('') this.justSent.set(true) window.setTimeout(() => { this.justSent.set(false) }, 1800) } onReplyKey(e: KeyboardEvent): void { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault() this.sendReply() } } folderClass(id: FolderId): string { return cn(railRow, this.activeFolder() === id && !this.activeLabel() ? railActive : railIdle) } labelClass(id: string): string { return cn(railRow, this.activeLabel() === id ? railActive : railIdle) } labelDotClass(color: string): string { return cn('size-2.5 shrink-0 rounded-full', color) } chipDotClass(id: string): string { return cn('size-1.5 rounded-full', labelMeta(id)?.color) } rowClass(id: string): string { return cn( 'group relative w-full px-4 py-3 text-left transition-colors', id === this.activeId() ? 'bg-primary/5' : 'hover:bg-muted/60', ) } senderClass(read: boolean): string { return cn('truncate text-sm', read ? 'font-medium' : 'font-semibold') } subjectClass(read: boolean): string { return cn('mt-0.5 truncate text-xs', read ? 'text-foreground/80' : 'font-semibold') } starClass(starred: boolean, size: string): string { return cn(size, starred ? 'fill-warning text-warning' : '') } replyTabClass(mode: ReplyMode): string { return cn(replyTabBase, this.replyMode() === mode ? replyTabActive : replyTabIdle) } }src/app/components/blocks/inbox/index.ts0.1 kB
export { UiInboxComponent, type Mail, type LabelTag, type FolderId, type ReplyMode } from './inbox.component'
Raw manifest:https://uipkge.dev/r/angular/inbox.json