Nasaq

Inbox

Unified inbox for chat, email and WhatsApp with a filterable conversation list, thread, composer, contact panel, assignment, snooze, find in thread, voice notes, location sharing, saved replies, reactions, a docked chat launcher and live new message toasts, driven by callbacks.

PreviewOpen ↗

Code

import { Inbox, type InboxAgent, type InboxConversation } from "@fadymondy/nasaq/web";import { useState } from "react";declare const initial: InboxConversation[];declare const agents: InboxAgent[];export function Support() {  const [list, setList] = useState(initial);  return (    <Inbox      conversations={list}      agents={agents}      currentAgentId="a1"      onSend={async (draft) => {        // POST the draft, then append the message you get back      }}      onUpdate={async (id, patch) => setList((all) => all.map((c) => (c.id === id ? { ...c, ...patch } : c)))}    />  );}

Chat · beta

Live examples and controls: Inbox in the lab.

Install

npx shadcn@latest add https://docs.nasaqui.com/r/inbox.json

The screen an agent lives in: every conversation from chat, email and WhatsApp in one list, the thread next to it, and who the customer is on the side. It stores nothing. You pass conversations and the component calls your callbacks (onSend, onUpdate, onReact); you update your state and pass the new list back.

When to use

  • A support or sales team answering customers over several channels.
  • Any admin screen that needs threads, an assignee, snooze and internal notes.

When not to use

Import

import { Inbox } from "@fadymondy/nasaq/web";
// inside this monorepo: "@nasaq/web"

Quick start

import { Inbox, type InboxAgent, type InboxConversation } from "@fadymondy/nasaq/web";
import { useState } from "react";

declare const initial: InboxConversation[];
declare const agents: InboxAgent[];

export function Support() {
  const [list, setList] = useState(initial);
  return (
    <Inbox
      conversations={list}
      agents={agents}
      currentAgentId="a1"
      onSend={async (draft) => {
        // POST the draft, then append the message you get back
      }}
      onUpdate={async (id, patch) => setList((all) => all.map((c) => (c.id === id ? { ...c, ...patch } : c)))}
    />
  );
}

Anatomy

Inbox                              data-slot="inbox" (data-compact under compactBelow px)
├─ conversation list               search, view tabs (Open, Unread, Snoozed, Closed, Archived), channel and assignment filters
│  └─ row                          avatar, colour dot, pin and mute marks, preview, unread badge, ConversationRowMenu
├─ thread                          header (assign, SnoozeMenu, close, find, pop out, contact), ThreadSearchBar, ChatThread
│  ├─ InboxMessageView             chat bubble, email card, internal note, event line
│  └─ InboxComposer                reply or note, emoji, saved replies, attach, VoiceRecorder, LocationPicker
├─ ContactInfoPanel                details, tags, notes, Media, Files and Links with a lightbox
├─ NewMessageToast                 a stack, for messages in conversations you are not reading
└─ InboxDock (separate)            launcher with unread count and small chat windows

API

Inbox: every div prop except children, plus:

PropTypeDefaultDescription
conversationsreadonly InboxConversation[]requiredChannel, contact, messages, status, assignee, unread, pinned, muted, colour, snooze.
agentsreadonly InboxAgent[]requiredTeammates for assignment and @mentions in notes.
currentAgentIdstringrequiredSigned-in agent.
selectedId / defaultSelectedId / onSelectedChangestring | nullnoneControlled or uncontrolled selection.
onSend(draft: InboxDraft) => Promise<void | { error? }>requiredReply, note, voice message or location. An error keeps the draft and shows the message.
onUpdate(id, patch: ConversationPatch) => void | Promise<void>requiredPin, archive, mute, colour, status, assignee, snooze, mark unread.
onReact(conversationId, messageId, emoji) => voidReaction toggled.
onRetry(conversationId, message) => voidRetry a failed send.
onPopOut(conversationId) => voidShows a pop out button (pair with InboxDock).
snippetsreadonly CannedSnippet[]noneSaved replies, typed after /, with {{name}} and {{agent}}.
loadingbooleanfalseSkeleton rows.
toastsbooleantrueToast for a new incoming message in another conversation (muted ones stay quiet).
simulateVoicebooleanfalseThe recorder makes a fake note without the microphone (demos and tests).
compactBelownumber820Under this width one pane shows at a time and contact opens in a sheet.
labelsPartial<InboxLabels>Override any string.

Parts you can use alone: InboxComposer, InboxMessageView, MessageReactions, ReactionPicker, ReplyQuote, MessageText, AttachmentList, VoiceRecorder, VoicePlayer, LocationPicker, LocationCard, LinkPreviewCard, SnoozeMenu, ConversationRowMenu, CannedPicker, ThreadSearchBar, ContactInfoPanel, NewMessageToast, InboxDock.

Helpers (pure, tested): filterConversations, countViews, findMatches, snoozePresets, applySnippet.

Examples

Docked chat windows next to any page

import { InboxDock, type InboxConversation, type InboxDraft } from "@fadymondy/nasaq/web";

declare const list: InboxConversation[];
declare const send: (d: InboxDraft) => Promise<void>;

export const Dock = () => <InboxDock conversations={list} me="a1" onSend={send} />;

Context menu

Each conversation row opens the same menu as its ⋯ button (status, labels, snooze, mute…) on right-click, Shift+F10 or the Menu key. contextMenu={false} opts out. Exported as ConversationContextMenu.

Accessibility

  • The list is a labelled region; the open row has aria-current. Unread count is text for screen readers.
  • The thread is a role="log" that follows new messages until the reader scrolls up.
  • Find: Enter and Shift+Enter move between matches, Escape closes. Menus use Base UI menu keyboard rules.
  • The location pad moves with arrow keys. The recorder announces its state and needs no pointer.
  • Toasts pause on hover and focus, and have a dismiss button.

RTL & i18n

  • English and Arabic follow the Nasaq locale. Email addresses, phone numbers, URLs and coordinates stay left to right.
  • Send, back and panel icons mirror. The map pad does not mirror: east is always to the right.
  • WhatsApp is written as text; no logo is used.

Styling & tokens

  • Built on chat, mention-textarea, rich-text-editor, notification-item, tabs, sheet, dialog and --nq-* tokens.
  • Target [data-slot="inbox"], [data-slot="inbox-composer"], [data-slot="inbox-dock"].
  • Colour labels use --nq-tag-*.

Do / Don't

  • Do send email as HTML only after your server sanitises it. The thread renders it read only through the editor schema.
  • Do resolve onSend with { error } instead of throwing.
  • Don't rely on colour alone to tell conversations apart.
  • Don't keep the microphone open after VoiceRecorder closes; it releases the stream itself.

On this page