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

@antzsoft/chat-web-sdk

v1.2.2

Published

Pluggable web chat UI SDK — React, Next.js, Vue, any JS framework

Readme

@antzsoft/chat-web-sdk

Drop-in chat UI for React and Next.js — real-time messaging with 5 layout modes, built-in auth, theming, file uploads, voice messages, and push notifications, all in a single component.

npm version license


Overview

@antzsoft/chat-web-sdk gives you a fully-featured chat UI that you can drop into any React or Next.js app with a single component. It re-exports everything from @antzsoft/chat-core and layers on top of it:

  • React components<AntzChat />, <AntzChatProvider />, individual layout shells, and lower-level chat UI components
  • Hooks — conversations, messages, send/edit/delete, typing, uploads, socket status, responsive breakpoints
  • 5 layout modes — floating bubble, embedded panel, sidebar drawer, fullscreen overlay, mobile webview
  • Built-in auth UI — optional login/register screens with token-based and provider-based flows
  • Theming — light/dark/system modes with full color, font, and border-radius customization
  • File uploads — images, video, audio, and documents with per-type size limits and progress tracking
  • Voice messages — in-browser recording and playback
  • Reactions — emoji reactions on messages
  • Mentions@-mention members in group chats (with admin-gated @all); mentions notify even a muted user
  • Read receipts — per-message delivery and read status
  • Typing indicators — real-time "is typing" display
  • Starred messages — bookmark important messages
  • Message search — search within a conversation
  • Groups — create and manage group conversations
  • Push notifications — Web Push (VAPID) integration via tokenProvider
  • Responsive — container-query-aware layout that adapts from 220 px to full-width

Installation

npm install @antzsoft/chat-web-sdk

Peer dependencies

npm install react react-dom

React 18 or higher is required.

Optional dependencies

# Required if you use the hook-based query API (useConversations, useMessages, etc.)
npm install @tanstack/react-query

# Required for icon rendering inside the built-in UI components
npm install lucide-react

Both packages are listed as optionalDependencies in the SDK. They are bundled into the SDK's own React components, so if you only use <AntzChat /> you do not need to install them separately — they will be resolved from the SDK's own node_modules. You only need to install them yourself if your application code imports from them directly.


Quick Start

// app/chat/page.tsx  (Next.js App Router)
'use client';

import { AntzChat } from '@antzsoft/chat-web-sdk';

export default function ChatPage() {
  return (
    <AntzChat
      config={{
        apiUrl: 'https://api.yourapp.com/api/v1',
        authToken: 'your-jwt-token',
      }}
      layout={{ mode: 'floating' }}
    />
  );
}

That is the entire integration. The component:

  1. Connects to the WebSocket server
  2. Loads conversations and messages
  3. Shows a floating chat bubble that expands into a full chat panel
  4. Manages auth state (restores session from localStorage between page reloads)

The AntzChat Component

<AntzChat /> is the main entry point. It composes AntzChatProvider (config, theme, socket), the selected layout shell, an optional auth gate, and the chat UI.

import { AntzChat } from '@antzsoft/chat-web-sdk';

<AntzChat
  config={webChatConfig}
  theme={themeConfig}
  layout={layoutConfig}
  features={featureConfig}
/>

Props (AntzChatProps)

| Prop | Type | Required | Description | |------|------|----------|-------------| | config | WebChatConfig | Yes | Server URLs, auth, upload settings | | theme | ThemeConfig | No | Colors, fonts, border radius, pattern | | layout | LayoutConfig | No | Layout mode and mode-specific options | | features | FeatureConfig | No | Feature flags and view mode | | conversationListFilters | ConversationListFilters | No | Server-side filters applied to the conversation list — changing these refetches from the server with correct pagination |


WebChatConfig

WebChatConfig extends the platform-agnostic AntzChatConfig from @antzsoft/chat-core. The web SDK pre-fills platformUploadFn (XHR-based upload with progress) and persistStorage (localStorage), so you do not need to supply them.

interface WebChatConfig {
  /** REST API base URL — e.g. "https://api.yourapp.com/api/v1" */
  apiUrl: string;

  /**
   * WebSocket server URL.
   * Defaults to apiUrl with the /api/vN path stripped.
   * The SDK connects to {socketUrl}/chat
   */
  socketUrl?: string;

  /**
   * Static JWT — use this when you already have a token at render time
   * (e.g. SSO / your own auth flow).
   * Use either authToken OR authProvider, not both.
   */
  authToken?: string;

  /**
   * Async function that returns a token string.
   * Called before REST requests and on socket reconnect.
   * Preferred when the host app manages its own token refresh lifecycle.
   */
  authProvider?: () => Promise<string>;

  /** Required for multi-tenant backends. Sent as X-Tenant-ID header. */
  tenantId?: string;

  /**
   * Enable payload-level transit encryption for all HTTP and socket traffic.
   * Uses ECDH key exchange (X25519/P-256) + AES-256-GCM to encrypt every
   * request, response, and socket event on the wire — independent of TLS.
   * Server must have TRANSIT_ENCRYPTION_ENABLED=true (default).
   * Default: true. Set false only for local development or debugging.
   * Safe to toggle anytime — no data migration needed (wire-only, never stored).
   */
  transitEncryption?: boolean;

  /** File upload constraints and callbacks. */
  upload?: {
    /**
     * Per-type size limits in MB, or a single number applied to all types.
     * Defaults: image 5 MB, video 25 MB, audio 10 MB, document 10 MB.
     */
    maxFileSizeMB?: number | {
      image?: number;
      video?: number;
      audio?: number;
      document?: number;
      default?: number;
    };

    /** Max number of files per message. Default: 10 */
    maxFilesPerMessage?: number;

    /** Which file categories users can attach. Default: all four types. */
    allowedTypes?: Array<'image' | 'video' | 'audio' | 'document'>;

    /** Called when a file fails validation or upload. */
    onUploadError?: (file: UploadableFile, error: Error) => void;

    /** Called with aggregate progress (0–100) during a batch upload. */
    onProgress?: (progress: number) => void;
  };

  /**
   * The user's ID in the external auth system.
   * Required for non-builtin authentication modes (antz, external, wso2).
   * Sent as the "x-user-id" request header when provided.
   */
  userId?: string;

  /**
   * Optional profile picture for non-builtin authentication modes.
   * Supply a publicly accessible URL or a base64-encoded data URI.
   * The server fetches/decodes the image, stores it in its own storage,
   * and serves back a 15-minute signed URL.
   */
  avatar?: {
    url?: string;
    base64?: string;
  };

  /**
   * Pre-filled by the web SDK — XHR-based upload with progress events.
   * Override only if you need a custom upload transport.
   */
  platformUploadFn?: PlatformUploadFn;

  /**
   * Pre-filled by the web SDK — canvas + CompressionStream compressor.
   * Override to use a custom compression implementation.
   * Set compression: { enabled: false } to disable without overriding.
   */
  platformCompressFn?: PlatformCompressFn;

  /**
   * Pre-filled by the web SDK — localStorage adapter for auth token persistence.
   * Override to use sessionStorage or an in-memory store.
   */
  persistStorage?: PersistStorage;

  /**
   * Optional compression settings. Compression is enabled by default on web.
   * Pass { enabled: false } to disable.
   */
  compression?: {
    /** Master switch. Default: true */
    enabled?: boolean;
    /** WebP encode quality, 0–1. Default: 0.85 */
    imageQuality?: number;
    /** Longest side cap in px. Default: 1920 */
    imageMaxDimension?: number;
    /** gzip text/JSON/CSV/XML/YAML/SVG. Default: true */
    compressDocuments?: boolean;
  };
}

Layout Modes

Pass a LayoutConfig to the layout prop to control how the chat UI is presented in your app.

interface LayoutConfig {
  mode?: 'floating' | 'embedded' | 'sidebar' | 'fullscreen' | 'mobile-webview';
  position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'; // floating only
  width?: string;       // CSS width value, e.g. '400px'
  height?: string;      // CSS height value, e.g. '600px'
  zIndex?: number;      // default: 9999
  initialOpen?: boolean;
  hideLauncher?: boolean;   // floating only — suppress the built-in FAB
  open?: boolean;           // floating only — controlled open state
  onOpenChange?: (open: boolean) => void;
  fullscreen?: boolean;     // floating only — controlled fullscreen state
  onFullscreenChange?: (fullscreen: boolean) => void;
  drawerSide?: 'left' | 'right'; // sidebar only, default: 'right'
  showBackdrop?: boolean;
  collapsibleList?: {          // two-column modes — see "Collapsible conversation list"
    enabled?: boolean;         // default: true
    minWidth?: number;         // default: 640
    maxWidth?: number;         // default: 1100
    defaultCollapsed?: boolean;// default: false
  };
}

Collapsible conversation list

In the two-column modes (fullscreen, embedded, sidebar) the user can collapse the conversation column away — the open chat then fills the container, and a burger button in the chat header brings the list back. The same « / burger pair the floating drawer already uses, applied to the side-by-side layout.

It is offered only inside a container-width band, because outside it the control is either pointless or harmful:

| Container width | Behaviour | | --- | --- | | < minWidth (640 px) | Already single-column — list and chat are separate screens with a back button, so there is nothing to collapse. | | minWidth … maxWidth | Two columns plus the « collapse button. This is the tablet / split-window band where both columns fit but neither has room to breathe. | | ≥ maxWidth (1100 px) | Two columns, list permanently visible. Both fit comfortably, and hiding the list would only leave dead space beside the chat. |

Widths are measured on the SDK's own container, not the window, so an embedded panel narrower than the viewport is judged as it actually renders.

<AntzChat
  config={config}
  layout={{
    mode: 'fullscreen',
    collapsibleList: {
      minWidth: 640,       // from here up, offer the collapse button
      maxWidth: 1280,      // from here up, pin the list open
      defaultCollapsed: false,
    },
  }}
/>

minWidth is clamped up to 640 — the SDK's own single-column breakpoint — since below it there is no second column to collapse. Pass maxWidth: Infinity to keep the toggle at every width above minWidth, or enabled: false to pin the list open everywhere.

The collapsed/expanded choice lives in component state for the session: it survives resizing out of the band and back (a user who collapsed at tablet width finds it collapsed again after a trip through desktop width), and resets on reload.


1. floating (default)

A fixed chat bubble anchored to a corner of the viewport. Clicking the bubble opens a panel above it. On mobile, the panel expands to full-screen with a semi-transparent backdrop.

Use case: Customer support widgets, contextual help, any situation where chat should not disrupt the main page layout.

<AntzChat
  config={config}
  layout={{
    mode: 'floating',
    position: 'bottom-right',   // 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'
    width: '400px',             // panel width on desktop
    height: '600px',            // panel height on desktop
    initialOpen: false,         // start with panel closed
    showBackdrop: false,        // show backdrop behind panel on desktop (always shown on mobile)
    zIndex: 9999,
  }}
/>

The FAB (floating action button) is 56 px and uses var(--antz-primary) as its background color. On mobile the margin auto-reduces from 24 px to 16 px.

Driving open/close from your own UI. Set hideLauncher: true to omit the built-in FAB, then drive the panel from open/onOpenChange — e.g. a tab in your own nav bar:

const [chatOpen, setChatOpen] = useState(false);

<AntzChat
  config={config}
  layout={{
    mode: 'floating',
    hideLauncher: true,
    open: chatOpen,
    onOpenChange: setChatOpen,
  }}
/>

Controlled fullscreen. By default the only way into fullscreen is the user tapping the expand button in the panel header — its own isFullscreen state is private to the SDK. Set fullscreen/onFullscreenChange (mirroring open/onOpenChange) to drive it yourself instead — for example, to always open chat fullscreen from your own nav tab regardless of viewport width:

<AntzChat
  config={config}
  layout={{
    mode: 'floating',
    hideLauncher: true,
    open: chatOpen,
    onOpenChange: setChatOpen,
    fullscreen: true,
    onFullscreenChange: () => {}, // called if the header's collapse button is used
  }}
/>

Once fullscreen is set, expanding and collapsing are driven entirely by the prop — the header's expand/collapse button calls onFullscreenChange instead of managing its own state, so keep it in sync if you want the button's clicks reflected back into your state.

Below the 640 px mobile breakpoint the panel is already full-bleed (inset: 0) regardless of fullscreen, so a controlled true there changes nothing about layout — it's honoured anyway (no special-casing for mobile), which just means it additionally suppresses the backdrop and the built-in FAB, same as it does on desktop. That makes a constant fullscreen: true safe to pass unconditionally for a host that wants one fullscreen chat experience at any viewport width.


2. embedded

Renders inline inside whatever container you give it. The chat panel fills 100% of the container's width and height. No fixed positioning — behaves like any other block element.

Use case: A dedicated /chat page, a chat section inside a dashboard layout, a split-pane view you control yourself.

// Give the container explicit dimensions
<div style={{ width: '100%', height: '600px' }}>
  <AntzChat
    config={config}
    layout={{
      mode: 'embedded',
      // width/height default to '100%' and fill the container
    }}
  />
</div>

3. sidebar

A slide-in drawer that covers the right (or left) edge of the viewport. Opens via initialOpen: true or by calling setOpen on the SidebarLayout component. A semi-transparent backdrop overlays the main content.

Use case: Help/support panels that slide in over existing content without a full navigation change, CRM sidebars.

<AntzChat
  config={config}
  layout={{
    mode: 'sidebar',
    drawerSide: 'right',     // 'left' | 'right'
    width: '420px',          // drawer width on desktop; auto-fills on mobile/tablet
    initialOpen: false,
    showBackdrop: true,      // default: true
    zIndex: 9999,
  }}
/>

Width behavior:

  • Mobile (< 640 px): 100vw
  • Tablet (640–1023 px): min(width, 100vw)
  • Desktop (≥ 1024 px): the configured width

4. fullscreen

A fixed overlay that covers the entire viewport (position: fixed; inset: 0). Unlike embedded, this uses fixed positioning and sits on top of all other content.

Use case: A full-screen chat experience triggered from a nav item, modals, or apps where chat is the primary UI.

<AntzChat
  config={config}
  layout={{
    mode: 'fullscreen',
    zIndex: 9999,
  }}
/>

5. mobile-webview

Fills 100dvw × 100dvh and is specifically optimized for rendering inside a React Native <WebView>. It applies CSS env(safe-area-inset-*) for iPhone notch and home-indicator clearance, disables iOS rubber-band scroll, injects a viewport-fit=cover meta tag if one is not already present, and forces a single-column layout regardless of container width.

Use case: Hybrid mobile apps that serve the chat UI inside a React Native WebView.

// In your Next.js or standalone web page served to the WebView:
<AntzChat
  config={config}
  layout={{ mode: 'mobile-webview' }}
/>
// In your React Native app:
import { WebView } from 'react-native-webview';

<WebView
  source={{ uri: 'https://yourapp.com/chat' }}
  style={{ flex: 1 }}
  contentInsetAdjustmentBehavior="never"
  bounces={false}
/>

Theming

ThemeConfig

interface ThemeConfig {
  /**
   * Color scheme mode.
   * 'system' reads the user's OS preference via prefers-color-scheme.
   * Default: 'light'
   */
  mode?: 'light' | 'dark' | 'system';

  colors?: {
    primary?: string;          // FAB, send button, active states (default: #25D366)
    primaryDark?: string;      // Hover/active variant of primary (default: #128C7E)
    chatBackground?: string;   // Message list background (default: #ECE5DD light / #0B141A dark)
    bubbleOutgoing?: string;   // Sent message bubble background (default: #DCF8C6 light / #005C4B dark)
    bubbleIncoming?: string;   // Received message bubble background (default: #FFFFFF light / #202C33 dark)
    sidebar?: string;          // Conversation list panel background
    header?: string;           // Conversation list header background
    inputBackground?: string;  // Message input bar background
    textPrimary?: string;      // Primary text color
    textSecondary?: string;    // Secondary / muted text color
  };

  fonts?: {
    family?: string;     // CSS font-family string
    sizeBase?: string;   // CSS font-size, e.g. '14px'
  };

  /** CSS border-radius applied to message bubbles and other rounded elements */
  borderRadius?: string;

  /**
   * Background pattern for the message area.
   * 'default' — subtle radial dot grid
   * 'none'    — flat background color
   * Any other string is treated as a raw CSS `background-image` value
   */
  backgroundPattern?: 'default' | 'none' | string;
}

Colors are injected as CSS custom properties on the root chat element, so your own CSS can reference them:

/* Available CSS variables (scoped to .antz-chat-root) */
--antz-primary
--antz-primary-dark
--antz-chat-bg
--antz-bubble-outgoing
--antz-bubble-incoming
--antz-sidebar
--antz-header
--antz-input-bg
--antz-text-primary
--antz-text-secondary
--antz-font-family
--antz-font-size-base
--antz-border-radius

Light theme example

<AntzChat
  config={config}
  theme={{
    mode: 'light',
    colors: {
      primary: '#25D366',
      primaryDark: '#128C7E',
      chatBackground: '#ECE5DD',
      bubbleOutgoing: '#DCF8C6',
      bubbleIncoming: '#FFFFFF',
      sidebar: '#FFFFFF',
      header: '#008069',
      inputBackground: '#F0F2F5',
      textPrimary: '#111B21',
      textSecondary: '#667781',
    },
  }}
/>

Dark theme example

<AntzChat
  config={config}
  theme={{
    mode: 'dark',
    colors: {
      primary: '#25D366',
      primaryDark: '#128C7E',
      chatBackground: '#0B141A',
      bubbleOutgoing: '#005C4B',
      bubbleIncoming: '#202C33',
      sidebar: '#111B21',
      header: '#202C33',
      inputBackground: '#2A3942',
      textPrimary: '#E9EDEF',
      textSecondary: '#8696A0',
    },
  }}
/>

Matching your app's brand colors

<AntzChat
  config={config}
  theme={{
    mode: 'system',   // automatically follows OS preference
    colors: {
      primary: '#6366F1',       // Indigo — your brand primary
      primaryDark: '#4F46E5',   // Darker shade for hover states
      header: '#4F46E5',        // Sidebar header matches brand
    },
    fonts: {
      family: '"Inter", sans-serif',
      sizeBase: '14px',
    },
    borderRadius: '12px',
    backgroundPattern: 'none',  // clean flat background
  }}
/>

Feature Flags (FeatureConfig)

All features default to true (enabled). Set a flag to false to hide the corresponding UI.

interface FeatureConfig {
  /** Emoji reactions on messages. Default: true */
  reactions?: boolean;

  /** Voice message recording and playback. Default: true */
  voiceMessages?: boolean;

  /** Per-message read receipts (sent / delivered / read ticks). Default: true */
  readReceipts?: boolean;

  /** "is typing..." indicator. Default: true */
  typingIndicators?: boolean;

  /** Star / bookmark messages. Default: true */
  starredMessages?: boolean;

  /** In-conversation message search. Default: true */
  messageSearch?: boolean;

  /** Group conversation creation and management. Default: true */
  groups?: boolean;

  /**
   * Show the built-in login and register UI when the user is not authenticated.
   * Set to false when you handle auth externally (authToken / authProvider).
   * Default: true
   */
  builtInAuth?: boolean;

  /**
   * 'full'                — shows conversation list + chat panel (default)
   * 'single-conversation' — skips the conversation list and opens directly
   *                         into the conversation specified by defaultConversationId
   */
  viewMode?: 'full' | 'single-conversation';

  /**
   * Used with viewMode: 'single-conversation'.
   * The SDK opens this conversation immediately on mount.
   */
  defaultConversationId?: string;

  /** Push notification integration. See Push Notifications section. */
  pushNotifications?: PushNotificationConfig;
}

Example: customer support widget (minimal feature set)

<AntzChat
  config={config}
  features={{
    reactions: false,
    voiceMessages: false,
    starredMessages: false,
    messageSearch: false,
    groups: false,
    builtInAuth: false,     // auth handled externally via authToken
    viewMode: 'single-conversation',
    defaultConversationId: 'support-conv-id',
  }}
/>

Zoo (Tenant) Selector

By default, the host app resolves a single tenantId and passes it to config.tenantId for the life of the mount — the SDK never changes it. Set features.zooSelector to let the SDK own selection instead, for users who belong to more than one zoo (tenant):

  • 1 zoo → auto-selected, no UI shown.
  • N zoos → a default is selected, with a header dropdown to switch.
  • The zoo list is still supplied by the host — the SDK adds no new API call, and does not fall back to config.tenantId when the selector is enabled.

Not supported under builtInAuth (one account maps to exactly one tenant there) — enabling it in that mode is a no-op with a console warning.

interface ZooSelectorConfig {
  /** Master switch. Default: false — existing integrations are untouched. */
  enabled?: boolean;

  /**
   * The host-supplied zoo list.
   *
   *   undefined | null — not loaded yet. Holds the current selection and shows
   *                       a skeleton on first boot. Never treated as "no zoos".
   *   []               — this user genuinely has zero zoos. Renders a
   *                       locked-out empty state, with no fallback to
   *                       config.tenantId.
   *
   * This distinction matters: a host whose fetch fails must pass null/undefined,
   * never []. Passing [] tells the SDK the user has no memberships at all.
   */
  zoos?: Array<{
    /** Coerced to string internally. */
    id: string | number;
    name: string;
    avatarUrl?: string;
    /** Secondary line in the dropdown, e.g. city or region. */
    description?: string;
    /** Visible but greyed and unselectable. Treated as removal if active. */
    disabled?: boolean;
  }> | null;

  /** Preferred zoo when nothing is remembered, and the fallback on eviction. */
  defaultZooId?: string;

  /** Persist the last pick per user in localStorage. Default: true */
  remember?: boolean;

  /** Render the built-in picker. Default: true. Set false and use useZooSelection(). */
  ui?: boolean;

  /** Noun shown in the UI: "Zoo" | "Organization" | "Site". Default: "Zoo" */
  label?: string;

  /**
   * What to do when access to the ACTIVE zoo is lost and more than one zoo is
   * left.
   *   'ask'  (default) — stop and let the user choose. Never picks for them.
   *   'auto' — pick a fallback silently and show a dismissible banner. For
   *            kiosk / embedded hosts where a blocking modal isn't viable.
   * With exactly one zoo left, both behave the same: auto-select and warn.
   */
  evictionBehaviour?: 'ask' | 'auto';

  onZooChange?: (zoo: ZooOption, previous: ZooOption | null) => void;
  /**
   * `fallback` is the auto-selected zoo when there was no choice to make, and
   * null when there was. Read `status` to tell the two null cases apart:
   * 'awaiting-choice' (user is picking) vs 'locked-out' (nothing left).
   */
  onZooRemoved?: (removed: ZooOption, fallback: ZooOption | null) => void;

  /** Confirm before switching. Default: only when an upload is in flight. */
  confirmOnSwitch?: boolean;
}

Example

<AntzChat
  config={{ apiUrl, authProvider, userId: '58' }}   // note: no tenantId needed
  features={{
    zooSelector: {
      enabled: true,
      zoos: myZoos,               // null while your fetch is in flight, [] if the user has none
      defaultZooId: '11',
    },
  }}
/>

Switching zoo is a full identity switch, not a filter over one inbox — different chat user id, different conversations, different unread counts. The SDK handles the socket reconnect, cache resets, and (best-effort) device-token cleanup for the zoo the user is leaving.

Headless hook

For hosts that set ui: false and render their own picker:

const {
  zoos,            // NormalisedZoo[]
  selectedZoo,     // NormalisedZoo | null
  selectZoo,       // (id: string) => void
  isSwitching,     // boolean
  status,          // 'loading' | 'ready' | 'awaiting-choice' | 'locked-out'
  evictedZoo,      // NormalisedZoo | null — the zoo access was lost to
  suggestedZooId,  // string | null — pre-highlight in your own chooser, never auto-applied
  dismissEviction, // () => void — clears the eviction warning banner only
} = useZooSelection();

'awaiting-choice' means access to the active zoo was lost and the user has more than one option left. Call selectZoo() with their answer; until then the SDK holds no tenant and makes no requests. Use status to hide your own chat launcher while 'locked-out' — there is nothing useful for it to open.


Authentication

The SDK supports three authentication modes. Use exactly one at a time.

Mode 1: authToken (static JWT)

Pass a token you have already fetched — from your SSO provider, a login API call, a cookie, etc. The SDK injects it into all REST requests and the WebSocket handshake.

// Fetch your token from your auth server, then pass it in
const token = await fetchMyAuthToken();

<AntzChat
  config={{
    apiUrl: 'https://api.yourapp.com/api/v1',
    authToken: token,
  }}
  features={{ builtInAuth: false }}
/>

The token is stored in localStorage via the built-in persistStorage adapter so it survives page reloads. If you need finer control over persistence, override persistStorage in the config.

Mode 2: authProvider (dynamic token getter)

Pass an async function that returns a fresh token. The SDK calls this before REST requests and on WebSocket reconnect, making it ideal for short-lived tokens or OAuth flows with refresh logic.

<AntzChat
  config={{
    apiUrl: 'https://api.yourapp.com/api/v1',
    authProvider: async () => {
      // Your token refresh logic here
      const { accessToken } = await refreshMyToken();
      return accessToken;
    },
  }}
  features={{ builtInAuth: false }}
/>

Mode 3: builtInAuth (built-in login/register UI)

Leave authToken and authProvider unset and enable builtInAuth (the default). The SDK renders a login form inside the chat panel. Users log in with email/password or register a new account.

<AntzChat
  config={{
    apiUrl: 'https://api.yourapp.com/api/v1',
    tenantId: 'your-tenant-id',   // required when using built-in auth on multi-tenant servers
  }}
  features={{
    builtInAuth: true,   // this is the default — shown here for clarity
  }}
/>

After a successful login the SDK stores the tokens in localStorage and immediately shows the chat UI. A "Register" link lets new users sign up within the same panel.

Logging out — onLogout()

Call onLogout() from your own logout flow. It is the single supported teardown entry point for all three auth modes — you do not need to clear any SDK storage key yourself.

import { onLogout } from '@antzsoft/chat-web-sdk';

async function handleLogout() {
  // Call BEFORE clearing your own session / navigating away — the chat token
  // must still be valid for the server-side steps below.
  await onLogout();

  yourAuth.clearSession();
  router.push('/login');
}

What it does, in order:

  1. Revokes the chat session's refresh token server-side (POST /auth/logout). Built-in auth only — in authToken / authProvider mode the SDK holds no refresh token and your app owns the session.
  2. Unsubscribes this browser's PushSubscription and unregisters the matching device token server-side, so the signed-out user stops receiving push on this device.
  3. Disconnects the socket, which also drops the transit/E2E session.
  4. Cancels in-flight queries, then clears the entire react-query cache.
  5. Resets every in-memory store — chat state, unsettled send payloads, message-cache LRU, file preview, in-app toasts, OS notifications, notification/push settings, the zoo selector, and the provider's identity-bootstrap state.
  6. Removes every per-identity localStorage key the SDK writes.
  7. Clears the persisted auth store.

Every step is best-effort — failures are logged, never thrown, so a host that never enabled push (or the zoo selector) still gets a clean teardown from one call. The network steps are time-boxed (3s each, 5s for service-worker readiness), so an unreachable server cannot wedge your logout.

Two keys deliberately survive logout: antz-chat:floating-size:* (a per-device panel-size preference, not user data) and antz-chat:device-id (identifies the browser, not the user — it must stay stable so the next person signing in on this device keeps a working push registration; what logout changes is which user that id is registered to on the server).

interface OnLogoutOptions {
  /** This device's push id, if you manage one explicitly (same value you pass
   *  as `PushNotificationConfig.deviceId` / `NotificationSettingsConfig.deviceId`).
   *  Omit to use the SDK's own auto-generated id. */
  deviceId?: string;
  /** How to reach the service worker holding the `PushSubscription`, for apps
   *  that register their own. Defaults to `navigator.serviceWorker.ready`. */
  getServiceWorkerRegistration?: () => Promise<ServiceWorkerRegistration>;
  /** Skip step 1 when your backend already revokes the chat session itself. */
  skipServerLogout?: boolean;
}

Push Notifications

The SDK never requests push permission on its own — that is always the parent app's responsibility. You supply a tokenProvider function that the SDK calls automatically after the user authenticates, or use the useDeviceToken hook for manual control from a settings screen.

The server stores one token document per physical device in chat_device_tokens. Every call to register() is an upsert keyed on deviceId — re-registering the same device updates the existing record, never creates a duplicate.

The deviceId rule

deviceId must be a stable UUID stored in localStorage — generated once on first visit, never regenerated. Losing it causes orphan token records in the server DB; the user may receive duplicate notifications until the stale token expires.

// Generate once, reuse forever
function getStableDeviceId(): string {
  let id = localStorage.getItem('chat-device-id');
  if (!id) { id = crypto.randomUUID(); localStorage.setItem('chat-device-id', id); }
  return id;
}

When to register and remove

  • On app init after login — if a push subscription already exists, re-register it. This refreshes lastUsedAt and catches any silent endpoint rotation the browser may have done. The SDK handles this automatically when you pass tokenProvider.
  • When the user enables notifications — request permission, subscribe, register. Only call pushManager.subscribe() at this point (not on every page load — that would prompt the user repeatedly).
  • On logout — call onLogout(). It does both halves for you (sub.unsubscribe() + remove(deviceId)) as part of the full teardown; there is no need to unregister the device separately.
  • On "Disable notifications" — call remove(deviceId). This deactivates the token on the server immediately. Also call sub.unsubscribe() to tell the browser to drop the subscription. (useNotificationSettings().setPushEnabled(false) already does both.)
interface PushNotificationConfig {
  /**
   * Stable UUID for this device. Store in localStorage, never regenerate.
   * Used as the upsert key — omitting it causes a new record on every page load.
   */
  deviceId?: string;

  /**
   * Called once after login. Return the push payload, or null if permission
   * is not granted. The SDK registers it with the server automatically.
   * On subsequent page loads with an existing subscription, return the
   * existing subscription rather than calling requestPermission() again.
   */
  tokenProvider?: () => Promise<RegisterDeviceTokenPayload | null>;

  /** Called after successful registration. Use to persist the deviceId. */
  onRegistered?: (deviceId: string) => void;

  /** Called if registration fails. Defaults to console.warn. */
  onError?: (error: Error) => void;
}

Web Push (VAPID) — auto-registration via tokenProvider

const VAPID_PUBLIC_KEY = 'your-vapid-public-key';

function base64url(buffer: ArrayBuffer | null): string {
  return buffer ? btoa(String.fromCharCode(...new Uint8Array(buffer))) : '';
}

<AntzChat
  config={config}
  features={{
    pushNotifications: {
      deviceId: localStorage.getItem('chat-device-id') ?? undefined,

      tokenProvider: async () => {
        if (!('serviceWorker' in navigator)) return null;

        // If permission already granted, re-register the existing subscription
        // (upsert — no duplicate created). Don't re-prompt the user.
        if (Notification.permission === 'granted') {
          const reg = await navigator.serviceWorker.ready;
          const existing = await reg.pushManager.getSubscription();
          if (existing) {
            return {
              deviceId: getStableDeviceId(),
              platform: 'web', provider: 'web-push',
              endpoint: existing.endpoint,
              p256dh: base64url(existing.getKey('p256dh')),
              auth: base64url(existing.getKey('auth')),
            };
          }
        }

        // No existing subscription — request permission and subscribe
        const permission = await Notification.requestPermission();
        if (permission !== 'granted') return null;

        const reg = await navigator.serviceWorker.ready;
        const sub = await reg.pushManager.subscribe({
          userVisibleOnly: true,
          applicationServerKey: VAPID_PUBLIC_KEY,
        });

        return {
          deviceId: getStableDeviceId(),
          platform: 'web', provider: 'web-push',
          endpoint: sub.endpoint,
          p256dh: base64url(sub.getKey('p256dh')),
          auth: base64url(sub.getKey('auth')),
        };
      },

      onRegistered: (deviceId) => {
        localStorage.setItem('chat-device-id', deviceId);
      },

      onError: (err) => {
        console.warn('Push registration failed:', err.message);
      },
    },
  }}
/>

Manual registration with useDeviceToken

For full control (e.g. a settings screen where the user explicitly opts in):

import { useDeviceToken } from '@antzsoft/chat-web-sdk';

function NotificationSettings() {
  const { register, remove } = useDeviceToken();

  async function enablePush() {
    const permission = await Notification.requestPermission();
    if (permission !== 'granted') return;

    const reg = await navigator.serviceWorker.ready;
    const sub = await reg.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: VAPID_PUBLIC_KEY,
    });

    await register({
      deviceId: getStableDeviceId(),
      platform: 'web',
      provider: 'web-push',
      endpoint: sub.endpoint,
      p256dh: base64url(sub.getKey('p256dh')),
      auth: base64url(sub.getKey('auth')),
    });
  }

  async function disablePush() {
    const reg = await navigator.serviceWorker.ready;
    const sub = await reg.pushManager.getSubscription();
    if (sub) await sub.unsubscribe(); // tells browser to drop the subscription
    await remove(getStableDeviceId()); // tells server to stop pushing
  }

  return (
    <div>
      <button onClick={enablePush}>Enable notifications</button>
      <button onClick={disablePush}>Disable notifications</button>
    </div>
  );
}

Notification Preferences

User notification preferences are stored server-side in chat_user_prefs. A record with all defaults is created automatically when a push token is first registered — so this API is always available after push setup. Call it via usersApi from @antzsoft/chat-core.

All fields are optional — only send what changed. Future preference fields are added to the same collection and same API; no new endpoints.

import { usersApi } from '@antzsoft/chat-core';
import type { UserPreferences } from '@antzsoft/chat-core';

// Read current preferences
const prefs = await usersApi.getPreferences();

// Partial updates — only send changed fields
await usersApi.updatePreferences({ notifyOnReaction: false });
await usersApi.updatePreferences({ messagePreview: false }); // privacy mode
await usersApi.updatePreferences({ notificationsEnabled: false }); // master off
await usersApi.updatePreferences({
  quietHours: { enabled: true, start: '23:00', end: '07:00', timezone: 'Europe/London' },
});

Preferences reference

| Field | Default | Description | |---|---|---| | notificationsEnabled | true | Master switch — false disables all push | | soundEnabled | true | Play sound with notifications | | messagePreview | true | Show message text in body. false = show "New message" only | | notifyOnMention | true | Notify when @mentioned in a group | | notifyOnReaction | true | Notify when someone reacts to your message | | notifyOnGroupInvite | true | Notify when added to a group | | quietHours.enabled | false | Enable quiet hours window | | quietHours.start | "22:00" | Start of quiet window (HH:MM) | | quietHours.end | "08:00" | End of quiet window (HH:MM) | | quietHours.timezone | "UTC" | IANA timezone for the quiet window |

Settings UI example

import { useState, useEffect } from 'react';
import { usersApi } from '@antzsoft/chat-core';
import type { UserPreferences } from '@antzsoft/chat-core';

function NotificationSettings() {
  const [prefs, setPrefs] = useState<UserPreferences | null>(null);

  useEffect(() => {
    usersApi.getPreferences().then(setPrefs);
  }, []);

  async function toggle(field: keyof UserPreferences, value: boolean) {
    await usersApi.updatePreferences({ [field]: value });
    setPrefs(prev => ({ ...prev, [field]: value }));
  }

  if (!prefs) return null;

  return (
    <div>
      <label>
        <input type="checkbox" checked={prefs.notificationsEnabled ?? true}
          onChange={e => toggle('notificationsEnabled', e.target.checked)} />
        Enable notifications
      </label>
      <label>
        <input type="checkbox" checked={prefs.messagePreview ?? true}
          onChange={e => toggle('messagePreview', e.target.checked)} />
        Show message preview
      </label>
      <label>
        <input type="checkbox" checked={prefs.notifyOnMention ?? true}
          onChange={e => toggle('notifyOnMention', e.target.checked)} />
        Mentions
      </label>
      <label>
        <input type="checkbox" checked={prefs.notifyOnReaction ?? true}
          onChange={e => toggle('notifyOnReaction', e.target.checked)} />
        Reactions
      </label>
      <label>
        <input type="checkbox" checked={prefs.notifyOnGroupInvite ?? true}
          onChange={e => toggle('notifyOnGroupInvite', e.target.checked)} />
        Group invites
      </label>
    </div>
  );
}

Notification Settings UI (built-in)

The SDK ships a ready-made settings surface so you don't have to build the screen above. It lives in the conversation-list header ⋮ menu → "🔔 Notifications" (shown in every layout mode's full view) and lets the user toggle:

  • In-app notifications — master switch + toast / sound / desktop / tab-badge sub-toggles. Stored per-browser in localStorage (antz-chat:notification-settings); overlays your features.inAppNotifications config.
  • Push notifications — per-device toggle: requests OS permission, subscribes via VAPID, registers with devicesApi, and ensures the account master notificationsEnabled is on. Turning it off tears down this device's subscription only, leaving other devices untouched. Requires features.notificationSettings.vapidPublicKey and a host service worker with a push handler.
  • Shared preferences — message preview, per-kind toggles, quiet hours (usersApi UserPreferences, synced across all devices).
<AntzChat
  config={{ /* … */ }}
  features={{
    inAppNotifications: { enabled: true },
    notificationSettings: {
      ui: true,                              // default — set false to hide the built-in panel
      vapidPublicKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
      // getServiceWorkerRegistration: () => navigator.serviceWorker.ready,  // default
    },
  }}
/>

Build your own UI — useNotificationSettings()

Set notificationSettings.ui = false and drive everything from the hook (works in any layout / view mode, including single-conversation):

import { useNotificationSettings } from '@antzsoft/chat-web-sdk';

function MyNotificationSettings() {
  const {
    inApp, setInApp, requestBrowserPermission,
    serverPrefs, setServerPrefs,
    push, setPushEnabled,
  } = useNotificationSettings();

  return (
    <>
      <label>
        <input type="checkbox" checked={inApp.enabled}
          onChange={e => setInApp({ enabled: e.target.checked })} />
        In-app notifications
      </label>

      <label>
        <input type="checkbox"
          checked={push.subscribed && (serverPrefs?.notificationsEnabled ?? true)}
          disabled={push.busy || !push.supported || !push.configured}
          onChange={e => setPushEnabled(e.target.checked)} />
        Push notifications {push.error && <span>— {push.error}</span>}
      </label>

      <label>
        <input type="checkbox" checked={serverPrefs?.notifyOnMention ?? true}
          onChange={e => setServerPrefs({ notifyOnMention: e.target.checked })} />
        Notify on mentions
      </label>
    </>
  );
}

You can also render just the built-in panel yourself:

import { NotificationSettingsPanel } from '@antzsoft/chat-web-sdk';

{open && <NotificationSettingsPanel onClose={() => setOpen(false)} />}

Hooks

All hooks must be called from within a component that is a descendant of AntzChatProvider (or <AntzChat />, which mounts the provider internally).

useAntzChat()

Access the resolved config, theme, layout, and features from anywhere inside the provider tree. Throws if called outside of AntzChatProvider.

import { useAntzChat } from '@antzsoft/chat-web-sdk';

function MyComponent() {
  const { config, theme, layout, features, resolvedMode } = useAntzChat();

  return (
    <div>
      <p>Connected to: {config.apiUrl}</p>
      <p>Theme: {resolvedMode}</p>
      <p>Reactions enabled: {String(features.reactions)}</p>
    </div>
  );
}

Returns:

| Field | Type | Description | |-------|------|-------------| | config | ResolvedConfig | Resolved config with all defaults filled | | theme | ThemeConfig | Raw theme prop as passed | | layout | LayoutConfig | Raw layout prop as passed | | features | Required<FeatureConfig> | Merged feature flags (user overrides + defaults) | | resolvedMode | 'light' \| 'dark' | Actual color mode after resolving 'system' |


useConversations(filters?)

Fetches and manages the conversation list. Initial load is via REST; all subsequent updates arrive through the WebSocket (no polling). Pass an optional filters object to narrow the list using server-side filtering — changing any filter triggers a new fetch with correct pagination.

import { useConversations } from '@antzsoft/chat-web-sdk';

function ConversationSidebar() {
  // No filters — full list
  const { conversations, isLoading } = useConversations();

  // With server-side filters — each unique filter set is a separate query cache entry
  const { conversations: groups }  = useConversations({ type: 'group' });
  const { conversations: pinned }  = useConversations({ isPinned: true });
  const { conversations: unread }  = useConversations({ hasUnread: true });
  const { conversations: results } = useConversations({ search: 'design' });
}

function ConversationSidebar() {
  const {
    conversations,
    isLoading,
    error,
    refetch,
    createDirect,
    createGroup,
    mute,
    unmute,
    pin,
    unpin,
    markUnread,
    markRead,
    leaveGroup,
    leaveAndDeleteGroup,
    deleteGroup,
  } = useConversations({ type: 'group', hasUnread: true });

  async function startChat(userId: string) {
    await createDirect.mutateAsync(userId);
  }

  async function newGroup() {
    await createGroup.mutateAsync({
      name: 'Project Alpha',
      memberIds: ['user-1', 'user-2'],
    });
  }

  return (
    <ul>
      {conversations.map((conv) => (
        <li key={conv.id}>
          {conv.name}
          <button onClick={() => pin.mutate(conv.id)}>Pin</button>
          <button onClick={() => mute.mutate({ id: conv.id })}>Mute</button>
          <button onClick={() => leaveGroup.mutate(conv.id)}>Exit Group</button>
          <button onClick={() => leaveAndDeleteGroup.mutate(conv.id)}>Exit &amp; Delete</button>
        </li>
      ))}
    </ul>
  );
}

Returns:

| Field | Type | Description | |-------|------|-------------| | conversations | Conversation[] | Sorted list (pinned first, then by last message time) | | isLoading | boolean | True during the initial fetch | | error | Error \| null | Fetch error | | refetch | () => void | Manually re-fetch the conversation list | | createDirect | UseMutationResult | Create a direct message conversation with a userId | | createGroup | UseMutationResult | Create a group conversation | | mute | UseMutationResult | Mute a conversation (optionally until a date) | | unmute | UseMutationResult | Unmute a conversation | | pin | UseMutationResult | Pin a conversation to the top | | unpin | UseMutationResult | Unpin a conversation | | markUnread | UseMutationResult<void, Error, string> | Flag a conversation unread, independent of unreadCount | | markRead | UseMutationResult<void, Error, string> | Clear the manual unread flag | | leaveGroup | UseMutationResult<void, Error, string> | Exit a group — stays in list as read-only. Auto-promotes if last admin. | | leaveAndDeleteGroup | UseMutationResult<void, Error, string> | Exit a group and remove it from the list in one call ("Exit and Delete"). | | deleteGroup | UseMutationResult<void, Error, string> | Hide a conversation from the list. For DMs this is "Delete Chat"; for groups already exited this is "Delete Group". Any participant can call this — no admin role required. |

Accepted filters (ConversationListFilters):

| Filter | Type | Description | |--------|------|-------------| | type | 'direct' \| 'group' | Show only DMs or only group conversations | | isPinned | boolean | true = only pinned, false = only unpinned | | isMuted | boolean | true = only muted, false = only unmuted | | hasUnread | boolean | Only conversations with at least 1 unread message | | search | string | Server-side text search on group name / description | | role | 'admin' \| 'member' | Filter by the current user's role in conversations | | hasAttachments | boolean | Filter by whether the last message has attachments | | attachmentType | 'image' \| 'video' \| 'document' \| 'audio' | Filter by last message attachment type | | notificationsEnabled | boolean | Filter by notification setting |

All filters are applied server-side. Omit page and limit to receive all matching conversations in one response. Pass both to opt into paginated results.

Passing filters to <AntzChat />

Pass conversationListFilters directly on the component. The conversation list refetches from the server whenever the filter object changes — pagination is always correct.

// Show only group conversations
<AntzChat
  config={config}
  conversationListFilters={{ type: 'group' }}
/>

// Show only unread conversations
<AntzChat
  config={config}
  conversationListFilters={{ hasUnread: true }}
/>

// Combine filters
<AntzChat
  config={config}
  conversationListFilters={{ type: 'group', isPinned: true }}
/>

Unread counts

Each Conversation object in useConversations() already has unreadCount populated from the initial REST fetch. The socket then keeps it live — you don't need to poll.

Chat icon badge — the complete pattern

useConversations() is already subscribed to every conversation_updated and unread_count_changed socket event internally. Summing unreadCount from the hook is all you need — no extra socket listener, no polling:

import { useConversations } from '@antzsoft/chat-web-sdk';

function ChatIconButton({ onClick }: { onClick: () => void }) {
  const { conversations } = useConversations();

  // Recalculates automatically every time any conversation's unreadCount changes
  const totalUnread = conversations.reduce(
    (sum, c) => sum + (c.unreadCount ?? 0), 0
  );

  return (
    <button onClick={onClick} style={{ position: 'relative' }}>
      💬
      {totalUnread > 0 && (
        <span style={{
          position: 'absolute',
          top: -6, right: -6,
          backgroundColor: '#e53935',
          color: '#fff',
          borderRadius: '50%',
          minWidth: 18, height: 18,
          fontSize: 11, fontWeight: 700,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          padding: '0 4px',
        }}>
          {totalUnread > 99 ? '99+' : totalUnread}
        </span>
      )}
    </button>
  );
}

The update chain is fully automatic:

New message sent
  → server emits conversation_updated with DB-accurate unreadCount
  → SocketProvider updates useConversations() cache
  → totalUnread recalculates
  → badge re-renders

Reading a conversation clears it the same way via unread_count_changed, including on other tabs/devices of the same user.

When to call the REST APIs

The socket may have been down (tab was backgrounded, network drop, etc.). Refresh from the server on:

import { conversationsApi } from '@antzsoft/chat-core';
import type { UnreadSummary } from '@antzsoft/chat-core';

// On app focus / visibility change — catch up after tab was hidden
useEffect(() => {
  function onVisible() {
    if (document.visibilityState === 'visible') {
      // Option A — refetch the full conversation list (also updates unreadCount)
      refetch();

      // Option B — lightweight, just the counts (no full list fetch)
      conversationsApi.getUnreadSummary().then((summary: UnreadSummary) => {
        console.log('Total unread:', summary.totalUnread);
        // summary.byConversation = [{ conversationId, unreadCount }, ...]
        // Update your own state / badge here
      });
    }
  }
  document.addEventListener('visibilitychange', onVisible);
  return () => document.removeEventListener('visibilitychange', onVisible);
}, [refetch]);

// Refresh a single conversation after a push notification opens it
const { unreadCount } = await conversationsApi.getUnreadCount(conversationId);

How the socket events work

The server emits to the user's private room — all browser tabs and devices of the same user receive these simultaneously:

// conversation_updated — fires when a new message arrives
// unreadCount is server-calculated (always accurate, not optimistic)
socket?.on('conversation_updated', ({ conversationId, unreadCount, lastMessage }) => {
  // The web SDK's SocketProvider handles this automatically —
  // useConversations() updates in real-time with no extra code needed
});

// unread_count_changed — fires when the user reads a conversation
// Also fires on your OTHER devices so they clear the badge too
socket?.on('unread_count_changed', ({ conversationId, unreadCount }) => {
  // unreadCount is 0 here — the SDK handles this automatically as well
});

Both events are handled internally by the SDK's SocketProvider. You only need to listen manually if you're building a custom badge (e.g. browser tab title or favicon).

conversation_deleted

Emitted only to the acting user's own sockets when they self-delete a conversation (delete()), exit+delete a group (leave(id, true)), or hide an already-exited group (delete()). Other participants never receive this event. The web SDK's SocketProvider handles it automatically — the conversation is removed from the useConversations() cache across all tabs. Only listen manually if you need a side effect such as navigation:

import { tryGetSocket } from '@antzsoft/chat-core';

useEffect(() => {
  const socket = tryGetSocket();
  if (!socket) return;

  const onDeleted = ({ conversationId }: { conversationId: string }) => {
    if (activeConversationId === conversationId) {
      router.push('/conversations');
    }
  };

  socket.on('conversation_deleted', onDeleted);
  return () => { socket.off('conversation_deleted', onDeleted); };
}, [activeConversationId]);
// Custom tab title badge
import { tryGetSocket } from '@antzsoft/chat-core';

useEffect(() => {
  const socket = tryGetSocket();
  if (!socket) return;

  const update = () => {
    const total = conversations.reduce((s, c) => s + (c.unreadCount ?? 0), 0);
    document.title = total > 0 ? `(${total}) Antz Chat` : 'Antz Chat';
  };

  socket.on('conversation_updated', update);
  socket.on('unread_count_changed', update);
  return () => { socket.off('conversation_updated', update); socket.off('unread_count_changed', update); };
}, [conversations]);

useMessages(conversationId)

Infinite-scroll message list for a conversation. Messages are loaded oldest-to-newest in pages of 40. After the initial load, new messages arrive via the WebSocket.

import { useMessages } from '@antzsoft/chat-web-sdk';

function MessageFeed({ conversationId }: { conversationId: string }) {
  const {
    messages,
    isLoading,
    isFetchingMore,
    hasMore,
    fetchMore,
    refresh,
    error,
  } = useMessages(conversationId);

  return (
    <div onScroll={(e) => {
      const el = e.currentTarget;
      // Load older messages when scrolled near the top
      if (el.scrollTop < 100 && hasMore && !isFetchingMore) {
        fetchMore();
      }
    }}>
      {messages.map((msg) => (
        <div key={msg.id}>{msg.content.text}</div>
      ))}
    </div>
  );
}

Returns:

| Field | Type | Description | |-------|------|-------------| | messages | Message[] | Flat array of all loaded messages, newest first | | isLoading | boolean | True during the initial page fetch | | isFetchingMore | boolean | True while loading an older page | | hasMore | boolean | True if there are older messages to load | | fetchMore | () => void | Load the next (older) page | | refresh | () => void | Invalidate and re-fetch all pages | | error | Error \| null | Fetch error |


useChat(conversationId)

The primary hook for all message-level interactions: send, edit, delete, typing, mark read, and reply/edit state.

import { useChat } from '@antzsoft/chat-web-sdk';

function ChatInput({ conversationId }: { conversationId: string }) {
  const {
    sendMessage,
    editMessage,
    deleteMessage,
    startTyping,
    stopTyping,
    markRead,
    replyingTo,
    setReplyingTo,
    editingMessage,
    setEditingMessage,
  } = useChat(conversationId);

  const [text, setText] = useState('');
  const [files, setFiles] = useState<File[]>([]);

  return (
    <form onSubmit={async (e) => {
      e.preventDefault();
      if (editingMessage) {
        await editMessage(editingMessage.id, text);
      } else {
        await sendMessage(text, files);
      }
      setText('');
      setFiles([]);
    }}>
      {replyingTo && (
        <div>Replying to: {replyingTo.content?.text}
          <button type="button" onClick={() => setReplyingTo(null)}>Cancel</button>
        </div>
      )}
      <input
        value={text}
        onChange={(e) => { setText(e.target.value); startTyping(); }}
        onBlur={stopTyping}
      />
      <input type="file" multiple onChange={(e) => setFiles(Array.from(e.target.files ?? []))} />
      <button type="submit">Send</button>
    </form>
  );
}

sendMessage optimistically inserts the message into the cache before the server confirms, so the UI feels instant. If the socket emit fails, the message is marked with deliveryStatus: 'failed'. Call retrySendMessage(messageId) on a failed message to resend it safely — it reuses the exact original tempId and already-uploaded attachment fileIds (no re-upload), so the server can recognize a retry and avoid creating a duplicate if the original send actually landed before the failure was reported. The built-in MessageItem already wires this to a tappable "Retry" on the failed indicator and a "Retry" menu entry — no extra code needed with the prebuilt UI.

Returns:

| Field | Type | Description | |-------|------|-------------| | sendMessage | (text: string, files?: File[]) => Promise<void> | Send text and/or file attachments | | retrySendMessage | (failedMessageId: string) => Promise<void> | Resend a message whose deliveryStatus is 'failed', reusing its original tempId for retry-safety (v1.4.4+). No-op if this hook instance never sent that message (e.g. a page reload). | | editMessage | (messageId: string, newText: string) => Promise<void> | Edit a sent message | | deleteMessage | (messageId: string) => Promise<void> | Delete a message for everyone. Own messages are subject to the conversation's delete window (default 30 min); group admins can delete any message with no time restriction. | | startTyping | () => void | Broadcast typing started. Safe to call on every keystroke — core leading-throttles it to one emit per 3s per conversation (v1.4.7+). Don't add your own throttle on top. | | stopTyping | () => void | Broadcast typing stopped. Never throttled — always reaches the server so peers' indicators clear promptly. | | markRead | (messageId?: string) => void | Mark conversation (or specific message) as read | | forwardMessage | (messageId: string, targetConversationIds: string[], attachmentIds?: string[], tempId?: string) => Promise<ForwardResult[]> | Forward a message into up to 5 other conversations (server reduces this to 1 if the message has already been forwarded many times — see below). Pass attachmentIds to forward only a subset of the source message's attachments; omit for the whole message. Pass tempId when retrying a previously-failed forward — reuse the SAME value, never a new one — so the server recognizes the retry and skips re-creating messages for targets that already succeeded; the built-in ForwardPicker already does this. No limit on how many times a message can be forwarded. REST-driven (not optimistic like sendMessage) since the targets aren't the conversation currently open — each target's cache is warmed once the server confirms. | | replyingTo | Message \| null | The message currently being replied to | | setReplyingTo | (msg: Message \| null) => void | Set or clear the reply target | | editingMessage | Message \| null | The message currently being edited | | setEditingMessage | (msg: Message \| null) => void | Set or clear the edit target | | forwardingMessage | Message \| null | The message currently staged in the forward picker | | setForwardingMessage | (msg: Message \| null) => void | Set or clear the forward target — setting it opens the built-in ForwardPicker modal in <MessageList> |

Forwarding. The built-in MessageItem component already exposes a "Forward" action in its context menu, which opens a conversation picker (ForwardPicker) capped at 5 targets to match the server's normal-case limit. If the message being forwarded is highly forwarded already (forwardDepth >= 5), the server reduces this to 1 target and returns any extra selections as regular { success: false } entries in the result array — the picker doesn't pre-detect this (it isn't known until the request lands), so handle a partial results array in your own "Forwarded to N/M" UI if you don't use the built-in picker as-is. There is no limit on the number of times a message can be forwarded — only fan-out width per call is ever reduced. Forwarded messages render a "Forwarded" (or "Forwarded many times" past 4 hops) label instead of a reply-style quote, since the message's own content already holds what was forwarded. See @antzsoft/chat-core's "Forward Message" docs for the underlying data shape (Message.forwardedFrom, one-hop-back lineage, attachment dedup, full limits table).


useSocket()

Real-time WebSocket connection status.

import { useSocket } from '@antzsoft/chat-web-sdk';

function ConnectionIndicator() {
  const { status, isConnected, isReconnecting, isError } = useSocket();

  if (isConnected) return <span style={{ color: 'green' }}>Connected</span>;
  if (isReconnecting) return <span style={{ color: 'orange' }}>Reconnecting…</span>;
  if (isError) return <span style={{ color: 'red' }}>Connection error</span>;
  return <span style={{ color: 'grey' }}>Connecting…</span>;
}

Returns:

| Field | Type | Description | |-------|------|-------------| | status | SocketStatus | Raw status string: 'connecting' \| 'connected' \| 'reconnecting' \| 'error' \| 'disconnected' | | isConnected | boolean | status === 'connected' | | isReconnecting | boolean | status === 'reconnecting' | | isError | boolean | status === 'error' |


useUpload(conversationId?)

Low-level file upload hook with MIME validation, size-limit enforcement, and progress tracking. Prefer using sendMessage from useChat for message attachments — useUpload is for standalone upload flows (e.g. a profile picture uploader).

import { useUpload } from '@antzsoft/chat-web-sdk';

function FileUploader({ conversationId }: { conversationId: string }) {
  const { upload, validateFiles, progress, isUploading, error } = useUpload(conversationId);

  async function handleFiles(files: File[]) {
    const { valid, errors } = validateFiles(files);
    errors.forEach(({ file, reason }) => console.warn(file.name, reason));

    if (valid.length > 0) {
      const result = await upload(valid);
      console.log('Uploaded:', result.successful);
      console.log('Failed:', result.failed);
    }
  }

  return (
    <div>
      <input
        type="file"
        multiple
        onChange={(e) => handleFiles(Array.from(e.target.files ?? []))}
      />
      {isUploading && <progress value={progress} max={100} />}
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </div>
  );
}

Returns:

| Field | Type | Description | |-------|------|-------------| | upload | (files: File[]) => Promise<BatchUploadResult> | Validate and upload files. Enforces maxFilesPerMessage. | | validateFiles | (files: File[]) => { valid: File[]; errors: Array<{ file: File; reason: string }> } | Validate without uploading (MIME check + size limit) | | progress | number | Aggregate upload progress, 0–100 | | isUploading | boolean | True while a batch upload is in progress | | error | string \| null | Last upload error message |


useResponsive()

Viewport-width-based breakpoints using a resize event listener. Useful for conditional rendering in custom components.

import { useResponsive } from '@antzsoft/chat-web-sdk';

function AdaptivePanel() {
  const { isMobile, isTablet, isDesktop, wid