npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ivanamato/whatsapp-inbox

v0.27.0

Published

Embeddable WhatsApp inbox UI library. Framework-agnostic — works with Vue, React, Angular, or plain HTML. Connects directly to the [Evolution API](https://doc.evolution-api.com/) from the browser.

Readme

WhatsApp Inbox

Embeddable WhatsApp inbox UI library. Framework-agnostic — works with Vue, React, Angular, or plain HTML. Connects directly to the Evolution API from the browser.

Built with Preact + Tailwind CSS v4. No backend required.

Features

  • Real-time messaging — Auto-polling keeps conversations and messages updated
  • Multi-device — Switch between multiple WhatsApp instances via a device selector
  • Imperative API — Open conversations, pre-fill the composer, switch devices, send messages, and fetch data from outside the inbox
  • Conversation pre-selection & pre-fill — Open a specific conversation programmatically with an optional draft message — ideal for CRM "reply to customer" flows
  • Pre-built messages — Per-device library of reusable text templates and audio PTT voice notes, selectable from a searchable picker in the composer
  • Custom chat actions — Configurable action buttons per chat row driven by the host app
  • Chat tags & filtering — Colored tag pills on each chat, resolved by the host app, with clickable filter chips to narrow the list
  • Template messages — Send WhatsApp-approved templates with header, body, and button parameters
  • Interactive messages — Button messages with up to 3 custom actions
  • Media support — Send and receive images, videos, audio, and documents
  • Voice recording — Record and send PTT voice messages directly from the composer
  • Image paste — Paste an image from the clipboard to send it with an optional caption
  • Message forwarding — Forward messages between conversations
  • Context menu — Right-click actions (delete, forward) on messages
  • Read-only mode — View-only access per device
  • CSS isolation — All styles use the wa: prefix to avoid collisions with host apps

Installation

Published to npm as @ivanamato/whatsapp-inbox.

npm install @ivanamato/whatsapp-inbox

Quick Start

Using the UMD bundle (any framework or plain HTML)

<link rel="stylesheet" href="whatsapp-inbox.css" />
<script src="whatsapp-inbox.umd.js"></script>

<div id="inbox" style="height: 100vh;"></div>

<script>
  const inbox = WhatsAppInbox.mount(document.getElementById('inbox'), {
    devices: [
      {
        id: 'main',
        label: 'My WhatsApp',
        apiUrl: 'https://your-evolution-api.com',
        instanceToken: 'your-instance-token',
        instanceName: 'your-instance',
      },
    ],
  });

  // Later: inbox.unmount();
</script>

Using the ES module

import { mount } from '@ivanamato/whatsapp-inbox';
import '@ivanamato/whatsapp-inbox/style.css';

const inbox = mount(document.getElementById('inbox'), {
  devices: [
    {
      id: 'main',
      label: 'My WhatsApp',
      apiUrl: 'https://your-evolution-api.com',
      instanceToken: 'your-instance-token',
      instanceName: 'your-instance',
    },
  ],
});

Documentation

mount(element, config)

Mounts the inbox into a DOM element and returns a WhatsAppInbox handle.

function mount(element: HTMLElement, config: WhatsAppMultiDeviceConfig): WhatsAppInbox
function unmount(element: HTMLElement): void

config.devices — Device configuration

The devices array is the only required field. Each entry represents one WhatsApp instance.

type DeviceConfig = {
  id: string;
  label?: string;
  apiUrl: string;
  instanceToken: string;
  instanceName: string;
  providerType?: 'evolution' | 'cloud';
  readonly?: boolean;
  prebuiltMessages?: PrebuiltMessage[];
};

| Field | Required | Description | |---|---|---| | id | Yes | Unique identifier for this device — used to target it via the imperative API | | apiUrl | Yes | Evolution API base URL (e.g. https://your-api.com) | | instanceToken | Yes | Per-instance token from Evolution API — see Security | | instanceName | Yes | Evolution API instance name | | label | No | Display name shown in the device selector dropdown | | providerType | No | 'evolution' (default) or 'cloud' | | readonly | No | If true, hides the composer — agents can read but not send | | prebuiltMessages | No | Reusable message templates and audio voice notes — see Pre-built Messages |

Minimal example:

mount(el, {
  devices: [
    {
      id: 'support',
      apiUrl: 'https://your-evolution-api.com',
      instanceToken: 'your-per-instance-token',
      instanceName: 'support-instance',
      label: 'Support',
    },
  ],
});

Multi-device example:

mount(el, {
  devices: [
    {
      id: 'sales',
      label: 'Sales',
      apiUrl: 'https://your-evolution-api.com',
      instanceToken: 'token-for-sales-instance',
      instanceName: 'sales',
    },
    {
      id: 'support',
      label: 'Support (read-only)',
      apiUrl: 'https://your-evolution-api.com',
      instanceToken: 'token-for-support-instance',
      instanceName: 'support',
      readonly: true,
    },
  ],
  defaultDeviceId: 'sales',
});

config.defaultDeviceId

ID of the device to select on mount. Defaults to the first device in the array.

mount(el, {
  devices: [/* ... */],
  defaultDeviceId: 'support',
});

config.translations

Override any UI string. All keys are optional — only provide what you want to change.

mount(el, {
  devices: [/* ... */],
  translations: {
    'messageView.typeMessage': 'Write a message…',
    'messageView.sendTemplate': 'Use template',
    'conversationList.searchPlaceholder': 'Search conversations',
  },
});

config.chatActions — Action buttons per chat row

Adds a three-dot menu to every chat row. Clicking it opens a dialog showing the contact's profile picture, name, phone number, device name, and your custom action buttons.

type ChatActionsResolver = (
  chat: Chat,
  device: DeviceConfig,
) => ChatAction[] | Promise<ChatAction[]>;

type ChatAction = {
  id: string;
  label: string;
  icon?: ComponentType<{ className?: string }>; // e.g. from lucide-react
  onClick: (chat: Chat, device: DeviceConfig) => void;
};

Synchronous example:

mount(el, {
  devices: [/* ... */],
  chatActions: (chat, device) => [
    {
      id: 'open-crm',
      label: 'Open in CRM',
      onClick: () => window.open(`https://crm.example.com/contacts/${chat.phoneNumber}`),
    },
    {
      id: 'tag-vip',
      label: 'Tag as VIP',
      onClick: () => fetch('/api/tag', {
        method: 'POST',
        body: JSON.stringify({ phone: chat.phoneNumber, tag: 'vip' }),
      }),
    },
  ],
});

Async example — different actions based on CRM status:

mount(el, {
  devices: [/* ... */],
  chatActions: async (chat, device) => {
    const customer = await fetch(`/api/crm/lookup?phone=${chat.phoneNumber}`)
      .then(r => r.json())
      .catch(() => null);

    if (customer) {
      return [
        {
          id: 'view-customer',
          label: 'See Customer',
          onClick: () => window.open(`/crm/customers/${customer.id}`),
        },
        {
          id: 'create-ticket',
          label: 'Create Support Ticket',
          onClick: () => createTicket(customer.id, chat),
        },
      ];
    }

    return [
      {
        id: 'create-contact',
        label: 'Create Contact in CRM',
        onClick: () => createContact(chat.phoneNumber, device.id),
      },
    ];
  },
});

Behavior:

  • The three-dot icon always renders on every chat row, regardless of whether chatActions is configured
  • Custom buttons only appear when a resolver is provided
  • A loading spinner shows while the resolver is running
  • After clicking an action, the dialog closes automatically
  • Dismiss with X, backdrop click, or Escape

config.chatTags — Colored tag pills on chat rows

Displays colored pills below the contact name in each row. All unique tags across all chats appear as clickable filter chips above the list.

type ChatTagsResolver = (
  chat: Chat,
  device: DeviceConfig,
) => ChatTag[] | Promise<ChatTag[]>;

type ChatTag = {
  id: string;       // Unique identifier — also used as the filter key
  label: string;    // Text shown in the pill
  color?: string;   // Text color (default: 'white')
  background?: string; // Background color (default: '#00a884')
};

Example:

mount(el, {
  devices: [/* ... */],
  chatTags: async (chat, device) => {
    const tags = [];

    if (chat.unreadCount && chat.unreadCount > 0) {
      tags.push({ id: 'unread', label: 'Unread', background: '#ff9800' });
    }

    if (chat.contactName) {
      tags.push({ id: 'saved', label: 'Saved Contact', background: '#2196f3' });
    }

    // Fetch custom tags from your backend
    const remote = await fetch(`/api/tags?phone=${chat.phoneNumber}`)
      .then(r => r.json())
      .catch(() => []);

    return [...tags, ...remote];
  },
});

Filtering behavior:

  • Clicking a tag chip above the list toggles it as a filter
  • When multiple chips are selected, only chats matching all selected tags are shown (AND logic)
  • Tags re-resolve automatically on every poll cycle

config.devices[].prebuiltMessages — Pre-built Messages

A per-device library of reusable message templates. When configured, a book icon button appears in the composer. Clicking it opens a searchable picker with live previews of every item.

type PrebuiltMessage = {
  id: string;
  label: string;    // Short title shown in the picker
  content: string;  // Text body, or base64-encoded media data
  type?: 'text' | 'audio' | 'image' | 'video'; // Default: 'text'
  mimeType?: string; // Required for media types. Defaults per type (see table below)
};

Types and picker behaviour

| type | Picker preview | Send action | Default mimeType | |---|---|---|---| | 'text' | Content text (truncated) | Fills the composer — agent can edit before sending | — | | 'audio' | Inline <audio> player — fully playable before sending | Explicit Send button (avoids accidental sends while using audio controls) | audio/ogg | | 'image' | Thumbnail <img> decoded from base64 | Click anywhere on the row | image/jpeg | | 'video' | Silent <video> preview frame decoded from base64 | Click anywhere on the row | video/mp4 |

All media types (audio, image, video) send immediately without touching the text composer.


Text templates

mount(el, {
  devices: [
    {
      id: 'support',
      // ...credentials...
      prebuiltMessages: [
        { id: 'greeting',  label: 'Greeting',          content: 'Hello! How can I help you today?' },
        { id: 'followup',  label: 'Follow up',          content: "I'm following up on our previous conversation. Please let me know if you have any questions." },
        { id: 'closing',   label: 'Closing',            content: 'Thank you for your time! Have a great day.' },
        { id: 'wait',      label: 'Please wait',        content: 'Just a moment while I look into that for you.' },
        { id: 'escalate',  label: 'Escalation notice',  content: "I'm going to transfer you to a specialist who can better assist you." },
      ],
    },
  ],
});

Audio voice notes

Pre-record voice messages and encode them as base64. The picker renders a fully playable <audio> player so agents can review the recording before hitting Send.

import { readFileSync } from 'fs';

const voiceGreeting = readFileSync('voice-greeting.ogg').toString('base64');
const voiceClosing  = readFileSync('voice-closing.ogg').toString('base64');

mount(el, {
  devices: [
    {
      id: 'support',
      // ...credentials...
      prebuiltMessages: [
        {
          id: 'voice-greeting',
          label: 'Voice Greeting',
          type: 'audio',
          mimeType: 'audio/ogg', // Recommended for WhatsApp
          content: voiceGreeting,
        },
        {
          id: 'voice-closing',
          label: 'Voice Closing',
          type: 'audio',
          mimeType: 'audio/ogg',
          content: voiceClosing,
        },
      ],
    },
  ],
});

Accepted audio formats: audio/ogg (recommended for WhatsApp), audio/webm, audio/mp4, audio/mpeg.


Images

The picker renders the actual image as a thumbnail so agents see exactly what they are about to send.

import { readFileSync } from 'fs';

const promoBanner = readFileSync('promo-banner.jpg').toString('base64');
const productShot = readFileSync('product-shot.png').toString('base64');

mount(el, {
  devices: [
    {
      id: 'sales',
      // ...credentials...
      prebuiltMessages: [
        {
          id: 'promo-banner',
          label: 'Promo Banner',
          type: 'image',
          mimeType: 'image/jpeg',
          content: promoBanner,
        },
        {
          id: 'product-shot',
          label: 'Product Shot',
          type: 'image',
          mimeType: 'image/png',
          content: productShot,
        },
      ],
    },
  ],
});

Accepted image formats: image/jpeg, image/png, image/webp, image/gif.


Videos

The picker renders a silent preview frame so agents can verify the clip before sending.

import { readFileSync } from 'fs';

const demoVideo = readFileSync('product-demo.mp4').toString('base64');

mount(el, {
  devices: [
    {
      id: 'sales',
      // ...credentials...
      prebuiltMessages: [
        {
          id: 'product-demo',
          label: 'Product Demo',
          type: 'video',
          mimeType: 'video/mp4',
          content: demoVideo,
        },
      ],
    },
  ],
});

Accepted video formats: video/mp4 (recommended), video/webm.


Mixed library (recommended for most teams)

prebuiltMessages: [
  // Text — agent reviews and can edit before sending
  { id: 'greeting', label: 'Greeting', content: 'Hello! How can I help you today?' },
  { id: 'followup', label: 'Follow up', content: "I'm following up on our previous conversation." },
  { id: 'closing',  label: 'Closing',  content: 'Thank you for your time! Have a great day.' },

  // Audio — agent listens via the inline player, then clicks Send
  { id: 'voice-hi',  label: 'Voice: Hi!',  type: 'audio', mimeType: 'audio/ogg', content: '<base64>' },
  { id: 'voice-bye', label: 'Voice: Bye!', type: 'audio', mimeType: 'audio/ogg', content: '<base64>' },

  // Image — thumbnail preview, click row to send
  { id: 'promo', label: 'Promo Banner', type: 'image', mimeType: 'image/jpeg', content: '<base64>' },

  // Video — silent frame preview, click row to send
  { id: 'demo', label: 'Product Demo', type: 'video', mimeType: 'video/mp4', content: '<base64>' },
],

Picker behaviour

  • The book icon button only appears when prebuiltMessages is configured and non-empty for the active device
  • Search — filters on label for all types; also matches content for text items. Base64 media content is never searched
  • Audio items have a dedicated Send button to prevent audio control interactions (play/seek/pause) from accidentally triggering a send
  • Image and video items — click anywhere on the row to send
  • Dismiss with Escape, the X button, or clicking the backdrop
  • Each device can have a completely different message library

Imperative API — WhatsAppInbox

mount() returns a WhatsAppInbox object for controlling the inbox programmatically.

type WhatsAppInbox = {
  getChats(): Promise<Chat[]>;
  getMessages(chatId: string, limit?: number): Promise<Message[]>;
  sendText(params: SendTextParams): Promise<SendResult>;
  getConnectionState(): Promise<'open' | 'close' | 'connecting'>;
  getActiveDevice(): string | null;
  setActiveDevice(deviceId: string): void;
  selectConversation(phoneNumber: string, prefillMessage?: string, deviceId?: string): void;
  openChat(phoneNumber: string, options?: { prefillMessage?: string; deviceId?: string }): void;
  unmount(): void;
};

getChats()

Fetches all chats for the currently active device.

const chats = await inbox.getChats();
console.log(`${chats.length} conversations on ${inbox.getActiveDevice()}`);

// Find a specific contact
const chat = chats.find(c => c.phoneNumber === '5511999999999');
console.log(chat?.contactName, chat?.unreadCount);

getMessages(chatId, limit?)

Fetches messages for a specific chat. limit defaults to 50.

const chats = await inbox.getChats();
const messages = await inbox.getMessages(chats[0].id, 100);

const lastInbound = messages.filter(m => m.direction === 'inbound').at(-1);
console.log('Last customer message:', lastInbound?.content);

sendText(params)

Sends a text message from the active device.

const result = await inbox.sendText({
  to: '[email protected]',
  body: 'Hello from the imperative API!',
});
console.log('Message ID:', result.messageId);

getConnectionState()

Checks whether the active device's WhatsApp connection is open.

const state = await inbox.getConnectionState();
// 'open' | 'close' | 'connecting'

if (state !== 'open') {
  alert('WhatsApp is disconnected — please scan the QR code.');
}

getActiveDevice() / setActiveDevice(deviceId)

Read or change the currently selected device.

console.log('Active device:', inbox.getActiveDevice()); // 'sales'

inbox.setActiveDevice('support');
console.log('Active device:', inbox.getActiveDevice()); // 'support'

selectConversation(phoneNumber, prefillMessage?, deviceId?)

Opens a conversation by phone number, optionally pre-filling the composer and/or switching to a specific device. This is the primary integration point for CRM and support tools.

// Open a conversation on the active device
inbox.selectConversation('5511999999999');

// Pre-fill the composer — the agent reviews and clicks Send
inbox.selectConversation('5511999999999', 'Hello, following up on your request!');

// Switch to a specific device, then open the conversation
inbox.selectConversation('5511999999999', undefined, 'support');

// Switch device + pre-fill — the most common CRM integration pattern
inbox.selectConversation('5511999999999', 'Hi! How can I help?', 'support');

Pre-fill behavior:

  • The message is written into the composer exactly as provided
  • The agent can edit it before sending — it is never sent automatically
  • Calling selectConversation again replaces the previous pre-fill (each call is tracked by an internal counter, so stale values are never applied)

Device switching behavior:

| Mode | deviceId provided | Result | |---|---|---| | Single (viewMode: 'single') | Different from active | Switches device → fetches chat list → selects conversation | | Single | Same as active or omitted | Searches current chat list immediately | | Merged (viewMode: 'all') | Any | No device switch — deviceId only disambiguates duplicate phone numbers |

If the phone number is not found in the chat list, nothing is selected.

CRM integration example:

// When an agent clicks "Reply" in your CRM
document.getElementById('reply-btn').addEventListener('click', () => {
  inbox.selectConversation(
    customer.phoneNumber,
    `Hi ${customer.firstName}, thanks for reaching out! How can I help?`,
    customer.assignedWhatsAppDevice,
  );
});

Notification / webhook integration example:

// Open the conversation when a new inbound message webhook fires
websocket.on('message', (event) => {
  if (event.type === 'inbound') {
    inbox.selectConversation(event.phoneNumber, undefined, event.deviceId);
  }
});

openChat(phoneNumber, options?)

Opens a conversation by phone number. Unlike selectConversation, this works even if the number has no existing chat history — it opens a blank message view so the agent can send the first message.

Pass a plain phone number in international format (digits only — no +, spaces, or @). The Evolution API resolves the WhatsApp JID automatically.

// Open a blank thread for any phone number
inbox.openChat('5511999999999');

// Pre-fill the composer
inbox.openChat('5511999999999', { prefillMessage: 'Hello, how can I help?' });

// Target a specific device
inbox.openChat('5511999999999', { deviceId: 'support' });

// Full form
inbox.openChat('5511999999999', {
  prefillMessage: 'Hi! Following up on your request.',
  deviceId: 'support',
});

Compared to selectConversation:

| | selectConversation | openChat | |---|---|---| | Number in chat list | ✅ Opens it | ✅ Opens it | | Number NOT in list | ❌ Does nothing | ✅ Opens blank thread |

Device behaviour: Same as selectConversation — if deviceId differs from the active device, the inbox switches to that device first. In merged mode, deviceId disambiguates duplicate numbers.


unmount()

Removes the inbox from the DOM and cleans up all subscriptions and timers.

// When navigating away
inbox.unmount();

TypeScript types

type Chat = {
  id: string;
  phoneNumber: string;
  contactName?: string;
  profilePicUrl?: string;
  lastActiveAt?: string;
  lastMessage?: {
    content: string;
    direction: 'inbound' | 'outbound';
    type?: string;
  };
  unreadCount?: number;
};

type Message = {
  id: string;
  direction: 'inbound' | 'outbound';
  content: string;
  createdAt: string;
  status?: string;
  phoneNumber: string;
  hasMedia: boolean;
  mediaData?: {
    url: string;
    contentType?: string;
    filename?: string;
    byteSize?: number;
  };
  messageType: string;
  caption?: string | null;
  filename?: string | null;
  mimeType?: string | null;
  metadata?: Record<string, unknown>;
};

type SendTextParams = { to: string; body: string };
type SendResult    = { messageId: string; status?: string };

Security

This library uses per-instance tokens instead of the Evolution API global key. Each token is scoped to a single WhatsApp instance — if a token is compromised, only that instance is affected.

To obtain the per-instance token:

curl -H "apikey: YOUR_GLOBAL_KEY" https://your-api.com/instance/fetchInstances

Each instance in the response has a token field — use that as instanceToken.

Never use the global API key in client-side code. The global key grants access to all instances and should only be used server-side for administration tasks.

Examples

See the examples/ directory:

  • examples/vue3/ — Vue 3 Composition API integration with chat tags, async chat actions, imperative API demos, and full Brazilian/international fixture data

To run the Vue 3 example against the mock server:

make docker        # start frontend (5173) + mock server (3002)
make example-vue3  # build library + serve example at http://localhost:5174/examples/vue3/

Development

Prerequisites

  • Node.js 20+
  • An Evolution API instance with CORS enabled (CORS_ORIGIN=*), or use the built-in mock server (no real API needed)

Setup

make install

Running with the mock server (no real API needed)

The fastest way to get started. Starts a local Evolution API mock on port 3002 and the Vite dev server on port 5173 — no credentials required.

With Docker (recommended):

make docker          # Build images and start in detached mode
# Open http://localhost:5173
make docker-restart  # Rebuild and restart (after code changes)
make docker-down     # Stop the stack

Without Docker:

make mock   # Starts mock server + Vite dev server concurrently
# Open http://localhost:5173

Both modes use devices.json pre-configured to point at http://localhost:3002 with two mock instances (MOCK1 and MOCK2).

Running with a real Evolution API

Replace devices.json with your real credentials:

{
  "devices": [
    {
      "id": "dev-1",
      "label": "Dev Instance",
      "apiUrl": "https://your-evolution-api.com",
      "instanceToken": "your-per-instance-token",
      "instanceName": "your-instance"
    }
  ]
}

Then run:

make dev

Commands

make install         # Install dependencies
make dev             # Start Vite dev server (reads devices.json)
make build           # Build library → dist/
make preview         # Preview the built library
make lint            # Run ESLint
make typecheck       # Run TypeScript type check
make clean           # Remove dist/ and node_modules/
make test            # Run unit tests (Vitest)
make test-e2e        # Run e2e tests — requires: make docker
make example-vue3    # Build library + serve Vue 3 example at localhost:5174 — requires: make docker
make mock            # Start mock server + dev server locally (no Docker)
make docker          # Start full stack with Docker Compose
make docker-build    # Rebuild Docker images from scratch
make docker-down     # Stop Docker Compose stack
make docker-restart  # Stop, rebuild, and restart Docker Compose stack
just release-minor   # Bump minor, push tag, create release → publishes to npm
just release-major   # Bump major, push tag, create release → publishes to npm

Build Output

dist/
  whatsapp-inbox.es.js    ~480 KB  (ES module)
  whatsapp-inbox.umd.js   ~305 KB  (UMD, works with <script> tags)
  whatsapp-inbox.css       ~37 KB  (prefixed styles)
  index.d.ts                       (TypeScript declarations)

All dependencies (including Preact) are bundled — no peer dependencies required.

Architecture

src/
  index.ts                  # Library entry: mount() / unmount() API
  App.tsx                   # Main layout (teal bar, sidebar, message view)
  dev.tsx                   # Dev server entry (reads devices.json)
  components/
    instance-selector.tsx   # Device selector dialog
    conversation-list.tsx   # Left sidebar with chat list
    message-view.tsx        # Right panel with messages and composer
    prebuilt-messages-dialog.tsx  # Pre-built message picker
    image-paste-modal.tsx   # Clipboard image paste overlay
    media-message.tsx       # Image/video/document/audio rendering
    audio-player.tsx        # Custom audio player with waveform
    forward-message-dialog.tsx
    template-selector-dialog.tsx
    interactive-message-dialog.tsx
    message-context-menu.tsx
    ui/                     # shadcn/ui components (dialog, scroll-area, etc.)
  lib/
    providers/
      types.ts              # WhatsAppProvider interface, DeviceConfig, Chat, Message
      evolution.ts          # Evolution API v2 implementation (browser fetch)
      index.ts              # createProvider() factory
    provider-context.tsx    # Preact context for multi-device state
  hooks/
    use-auto-polling.ts     # Polling hook with tab-visibility detection
  use-cases/
    use-app-state.ts        # Top-level app state (selected conversation, device)
    use-chat-list.ts        # Chat list fetching and polling
    use-message-thread.ts   # Message thread, sending, optimistic updates
    use-device-status.ts    # Connection status polling
mock-server/
  index.ts                  # Entry point (serves on MOCK_PORT, default 3002)
  app.ts                    # Hono router — all Evolution API routes
  fixtures.ts               # Per-instance fixture data (MOCK1, MOCK2)
  store.ts                  # In-memory state (sent messages, media, contacts, unread)

Key Patterns

  • No backend — All API calls (findChats, sendText, getMediaUrl, etc.) happen directly from the browser via the provider abstraction.
  • Per-instance tokens — Each device authenticates with its own scoped token, not the global API key.
  • Provider system — Implement the WhatsAppProvider interface to add new backends. Currently supports Evolution API v2.
  • Preact with compat — Uses Preact with preact/compat so React-ecosystem libraries work unchanged.
  • CSS isolation — Tailwind v4 with wa: prefix (e.g. wa:flex, wa:p-4). CSS variables namespaced as --wa-*.
  • Optimistic sends — Messages appear immediately in the thread with a pending status; the chat list refreshes after the send resolves.
  • Chat list reactivityConversationList exposes a refresh() ref method called after sending, template sends, and on mount.

Mock Server

The mock server (mock-server/) is a Hono HTTP server that replicates the Evolution API v2 contract for local development — no real WhatsApp credentials needed.

Implemented endpoints:

| Endpoint | Method | Description | |---|---|---| | /instance/connectionState/:instance | GET | Returns { instance: { state: 'open' } } | | /chat/findChats/:instance | POST | Returns chat list, dynamically updated with sent messages and unread counts | | /chat/findContacts/:instance | POST | Returns contacts, merged with dynamically created contacts | | /chat/findMessages/:instance | POST | Returns paginated messages; marks chat as read | | /chat/getBase64FromMediaMessage/:instance | POST | Returns stored base64 for sent media | | /message/sendText/:instance | POST | Stores message, triggers delivery progression and auto-reply | | /message/sendMedia/:instance | POST | Stores message + base64 payload | | /message/sendButtons/:instance | POST | Stores button message | | /chat/deleteMessageForEveryone/:instance | DELETE | Marks message deleted; emits REVOKE event |

Mock instances:

  • MOCK1 — Brazilian contacts (Ana Beatriz, Carlos Eduardo, Equipe Vendas group, Fernanda Lima, Roberto Mendes)
  • MOCK2 — International contacts (Sarah Johnson, James Wright, Product Team group, Miguel Torres)

Publishing

Package is published to npm as @ivanamato/whatsapp-inbox. A GitHub Actions workflow (.github/workflows/publish.yml) automatically builds and publishes on every GitHub Release using Trusted Publishing (OIDC).

  • Release commands: just release-minor or just release-major bump the version, push the tag, and create a GitHub release (which triggers the publish workflow).
  • publishConfig in package.json sets "access": "public".

License

MIT