Menu

Change language

GitHub repo

Inbox

blockcommunication

Three-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

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

Variants

Loading interactive previews…

Schema

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

Folder
interface Folder {
  id: FolderId
  label: string
  icon: LucideIconData
}
LabelTag
interface LabelTag {
  id: string
  label: string
  color: string
}
Mail
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[]
}

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: 'sarah@acme.com', initials: 'SC' },
        to: 'you@acme.com',
        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: 'marcus@acme.com', initials: 'MR' },
        to: 'you@acme.com',
        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: 'receipts@stripe.com', initials: 'St' },
        to: 'you@acme.com',
        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: 'no-reply@linear.app', initials: 'Li' },
        to: 'you@acme.com',
        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: 'priya@acme.com', initials: 'PS' },
        to: 'you@acme.com',
        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: 'reservations@united.com', initials: 'UA' },
        to: 'you@acme.com',
        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: 'mom@family.net', initials: 'M' },
        to: 'you@acme.com',
        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">&lt;{{ mail.from.email }}&gt;</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

Raw manifest:https://uipkge.dev/r/angular/inbox.json