@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.
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-sdkPeer dependencies
npm install react react-domReact 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-reactBoth 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:
- Connects to the WebSocket server
- Loads conversations and messages
- Shows a floating chat bubble that expands into a full chat panel
- 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-radiusLight 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.tenantIdwhen 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:
- Revokes the chat session's refresh token server-side (
POST /auth/logout). Built-in auth only — inauthToken/authProvidermode the SDK holds no refresh token and your app owns the session. - Unsubscribes this browser's
PushSubscriptionand unregisters the matching device token server-side, so the signed-out user stops receiving push on this device. - Disconnects the socket, which also drops the transit/E2E session.
- Cancels in-flight queries, then clears the entire react-query cache.
- 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.
- Removes every per-identity
localStoragekey the SDK writes. - 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
lastUsedAtand catches any silent endpoint rotation the browser may have done. The SDK handles this automatically when you passtokenProvider. - 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 callsub.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 yourfeatures.inAppNotificationsconfig. - Push notifications — per-device toggle: requests OS permission, subscribes
via VAPID, registers with
devicesApi, and ensures the account masternotificationsEnabledis on. Turning it off tears down this device's subscription only, leaving other devices untouched. Requiresfeatures.notificationSettings.vapidPublicKeyand a host service worker with apushhandler. - Shared preferences — message preview, per-kind toggles, quiet hours
(
usersApiUserPreferences, 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 & 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-rendersReading 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