@antzsoft/chat-core
v1.4.4
Published
Platform-agnostic core for Antz Chat — API, socket, stores, types. Works in browser, React Native (Expo or bare), and Node.js.
Downloads
1,815
Readme
@antzsoft/chat-core
Platform-agnostic TypeScript core for Antz Chat — API client, Socket.IO wrapper, Zustand stores, and a headless client class that works in browser, React Native (Expo or bare), and Node.js.
Overview
@antzsoft/chat-core provides the shared foundation that both @antzsoft/chat-web-sdk and @antzsoft/chat-rn-sdk are built on. You can also use it directly when you need headless control — bot integrations, custom UIs, server-side tooling, or any environment that doesn't match the higher-level SDK's assumptions.
Key capabilities:
- Axios HTTP client with automatic token injection, refresh handling, and multi-tenant headers
- Socket.IO wrapper with typed event emitters, ack-based operations, and connection-state management
- Zustand auth store with platform-portable token persistence
- Zustand chat store for UI state (active conversation, typing indicators, online presence, replies)
AntzChatClient— a single class that wires everything together for headless use- Full TypeScript types for all entities, API payloads, and socket events
The package ships both ESM (dist/index.js) and CJS (dist/index.cjs) builds, plus .d.ts declarations.
Installation
npm install @antzsoft/chat-coreaxios, socket.io-client, and zustand are regular dependencies — they are bundled in the package and require no separate install. There are no peer dependencies.
Using This SDK Independently (Custom UI)
@antzsoft/chat-core is a fully standalone SDK. You do not need @antzsoft/chat-web-sdk or @antzsoft/chat-rn-sdk to build a complete chat app — those packages only add pre-built UI components. This package gives you everything needed to build your own UI on any platform.
What you get out of the box
| Capability | What the SDK provides |
|---|---|
| Authentication | Login, register, logout, token refresh (automatic on 401) |
| Conversations | List, create (group/DM), update, delete, mute, pin, mark unread, leave, manage members |
| Messages | Send, edit, delete, react, star, pin, search, paginate, @mention |
| Mentions | Group @mentions with @all (admin-gated); token parse/build/render helpers; mention pierces mute |
| File uploads | Presigned URL pipeline — request URL → upload binary (multipart POST for S3/local, PUT for Azure) → confirm. Files ≥ 10 MB on S3 or local use chunked multipart (parallel parts → complete). |
| Real-time | Socket.IO wrapper — send/receive messages, typing, read receipts, presence |
| State management | Zustand auth store (persisted) + chat store (typing users, online status, reply/edit state) |
| Push notifications | Device token registration/removal API |
| TypeScript | Full types for every entity, API payload, and socket event |
What you must provide
The SDK has two required adapters that differ between platforms. You write them once — they are simple wrappers:
1. persistStorage — token persistence
The SDK stores auth tokens under the key "antz-chat-auth" using this adapter. Tokens survive page reloads (web) or app restarts (RN) if you point it at a persistent store.
Why it's required: The SDK is platform-agnostic — it can't assume localStorage exists (Node.js, RN) or AsyncStorage exists (web, Node.js). You tell it where to store tokens.
// Browser — localStorage
const persistStorage = {
getItem: (key: string) => localStorage.getItem(key),
setItem: (key: string, value: string) => localStorage.setItem(key, value),
removeItem: (key: string) => localStorage.removeItem(key),
};
// React Native — AsyncStorage
import AsyncStorage from '@react-native-async-storage/async-storage';
const persistStorage = {
getItem: (key: string) => AsyncStorage.getItem(key),
setItem: (key: string, value: string) => AsyncStorage.setItem(key, value),
removeItem: (key: string) => AsyncStorage.removeItem(key),
};
// Node.js / server — in-memory (tokens lost on restart, fine for bots)
const _store: Record<string, string> = {};
const persistStorage = {
getItem: (key: string) => _store[key] ?? null,
setItem: (key: string, value: string) => { _store[key] = value; },
removeItem: (key: string) => { delete _store[key]; },
};2. platformUploadFn — binary file upload
The SDK handles the full upload pipeline (requesting presigned URLs, confirming uploads) but delegates the actual binary transfer to this function. This is because the HTTP APIs differ between platforms — XHR on web, fetch/FileSystem on RN, fs on Node.js.
Why it's required: Sending binary data to S3/GCS presigned URLs works differently on each platform. You provide the right implementation for your environment.
// Browser — XHR with progress reporting
const platformUploadFn = async (presigned, file, onProgress) => {
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(presigned.method, presigned.uploadUrl);
Object.entries(presigned.headers).forEach(([k, v]) => xhr.setRequestHeader(k, v));
xhr.upload.onprogress = (e) => onProgress?.(e.loaded / e.total);
xhr.onload = () => xhr.status < 400 ? resolve() : reject(new Error(`${xhr.status}`));
xhr.onerror = () => reject(new Error('Network error'));
if (presigned.method === 'PUT') {
fetch(file.uri).then(r => r.blob()).then(blob => xhr.send(blob));
} else {
const fd = new FormData();
Object.entries(presigned.fields ?? {}).forEach(([k, v]) => fd.append(k, v));
fetch(file.uri).then(r => r.blob()).then(blob => { fd.append('file', blob, file.name); xhr.send(fd); });
}
});
};
// React Native — fetch (works on Expo and bare RN)
const platformUploadFn = async (presigned, file, onProgress) => {
onProgress?.(0);
let body: any;
if (presigned.method === 'POST' && presigned.fields) {
// S3 / local — multipart FormData with signed policy fields
const fd = new FormData();
Object.entries(presigned.fields).forEach(([k, v]) => fd.append(k, v as string));
fd.append('file', { uri: file.uri, name: file.name, type: file.type } as any);
body = fd;
} else {
// Azure — raw blob PUT
body = { uri: file.uri, name: file.name, type: file.type } as any;
}
const res = await fetch(presigned.uploadUrl, { method: presigned.method, headers: presigned.headers, body });
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
onProgress?.(1);
};
// Node.js — fs + fetch (Node 18+)
import { readFileSync } from 'fs';
const platformUploadFn = async (presigned, file) => {
let body: any;
let headers: Record<string, string> = { ...presigned.headers };
if (presigned.method === 'POST' && presigned.fields) {
// S3 / local — multipart FormData with signed policy fields
const { FormData, Blob } = await import('node:buffer') as any;
const fd = new FormData();
Object.entries(presigned.fields).forEach(([k, v]) => fd.append(k, v as string));
fd.append('file', new Blob([readFileSync(file.uri.replace('file://', ''))], { type: file.type }), file.name);
body = fd;
} else {
// Azure — raw buffer PUT
body = readFileSync(file.uri.replace('file://', ''));
headers['Content-Type'] = file.type;
}
const res = await fetch(presigned.uploadUrl, { method: presigned.method, headers, body });
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
};Note: If your app does not use file uploads at all, you can pass a no-op:
const platformUploadFn = async () => {};
Minimum setup — 5 lines
import { AntzChatClient } from '@antzsoft/chat-core';
const client = new AntzChatClient({
apiUrl: 'https://your-server.com/api/v1',
persistStorage, // your adapter from above
platformUploadFn, // your adapter from above
});
await client.auth.login({ email: '[email protected]', password: 'secret' });
await client.connect(); // opens socket
client.socket.on('new_message', (evt) => console.log(evt.message));
client.socket.emit.joinRoom('your-conversation-id');Authentication options
// Option 1 — SDK manages login/logout (built-in auth)
await client.auth.login({ email, password });
// Option 2 — pre-authenticated token from your own auth system
const client = new AntzChatClient({ apiUrl, persistStorage, platformUploadFn, authToken: 'eyJ...' });
// Option 3 — dynamic token provider (SSO, token rotation)
const client = new AntzChatClient({
apiUrl, persistStorage, platformUploadFn,
authProvider: async () => {
const token = await yourApp.getAccessToken(); // your auth library
return token;
},
});What you build yourself
When using core SDK directly, you are responsible for building:
- Your own UI components (conversation list, message bubbles, input box, etc.)
- Wiring socket events to your UI state (the
useChatStoreZustand store helps with this) - File picker integration (to produce
UploadableFileobjects foruploadFiles)
The rest of this README documents all the APIs, stores, types, and socket events in full detail.
Quick Start
The fastest way to go headless: instantiate AntzChatClient, connect, and start listening.
import {
AntzChatClient,
type AntzChatConfig,
type NewMessageEvent,
} from '@antzsoft/chat-core';
// Minimal localStorage adapter (browser)
const localStorageAdapter = {
getItem: (key: string) => localStorage.getItem(key),
setItem: (key: string, value: string) => localStorage.setItem(key, value),
removeItem: (key: string) => localStorage.removeItem(key),
};
// Platform upload function (browser — XHR with progress)
const platformUploadFn = async (presigned, file, onProgress) => {
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(presigned.method, presigned.uploadUrl);
Object.entries(presigned.headers).forEach(([k, v]) => xhr.setRequestHeader(k, v));
xhr.upload.onprogress = (e) => onProgress?.(e.loaded / e.total);
xhr.onload = () => (xhr.status < 400 ? resolve() : reject(new Error(`Upload failed: ${xhr.status}`)));
xhr.onerror = () => reject(new Error('Network error during upload'));
// S3 and local storage return method:'POST' with signed fields → multipart FormData.
// Azure returns method:'PUT' → raw blob body.
if (presigned.method === 'POST' && presigned.fields) {
const fd = new FormData();
Object.entries(presigned.fields).forEach(([k, v]) => fd.append(k, v));
fetch(file.uri).then(r => r.blob()).then(blob => { fd.append('file', blob, file.name); xhr.send(fd); }).catch(reject);
} else {
fetch(file.uri).then(r => r.blob()).then(blob => xhr.send(blob)).catch(reject);
}
});
};
const config: AntzChatConfig = {
apiUrl: 'https://api.yourapp.com/api/v1',
persistStorage: localStorageAdapter,
platformUploadFn,
};
const client = new AntzChatClient(config);
// Log in (stores tokens automatically)
const { user, tokens } = await client.auth.login({ email: '[email protected]', password: 'secret' });
console.log('Logged in as', user.displayName);
// Open a socket connection
await client.connect();
// Listen for incoming messages
client.socket.on('new_message', (event: NewMessageEvent) => {
console.log('[new message]', event.message.content.text);
});
// Join a conversation room
client.socket.emit.joinRoom('conv-123');
// Send a message over the socket
await client.socket.emit.sendMessage({
conversationId: 'conv-123',
text: 'Hello!',
tempId: crypto.randomUUID(),
});
// Or use the REST API directly
const history = await client.messages.list('conv-123', { limit: 50 });
console.log('Loaded', history.data.length, 'messages');
// Clean up
client.disconnect();Configuration
AntzChatConfig
interface AntzChatConfig {
/**
* Base URL for all REST API requests.
* Include the versioned path segment — e.g. "https://api.yourapp.com/api/v1".
* Required.
*/
apiUrl: string;
/**
* Platform-specific function that performs the actual binary upload
* to a presigned URL. The core never touches binary data directly —
* it delegates to this function so the same codebase works on web and RN.
* Required.
*/
platformUploadFn: PlatformUploadFn;
/**
* Key-value storage adapter for auth token persistence.
* Web: wrap localStorage. RN: wrap AsyncStorage.
* Required.
*/
persistStorage: PersistStorage;
/**
* WebSocket server URL. Defaults to apiUrl with any "/api/vN" suffix stripped.
* The socket connects to "{socketUrl}/chat".
* Optional.
*/
socketUrl?: string;
/**
* Static JWT. Pass when you have a token from outside the SDK
* (e.g. SSO flow completed by the host app). Skips the login step.
* Use this OR authProvider, not both.
* Optional.
*/
authToken?: string;
/**
* Async function that returns a fresh access token. Called before every
* request and on socket reconnect. Preferred when the host app manages
* its own auth lifecycle.
* Optional.
*/
authProvider?: () => Promise<string>;
/**
* Tenant identifier for multi-tenant backends.
* Sent as the "X-Tenant-ID" request header when provided.
* Optional.
*/
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;
/**
* 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.
* Optional.
*/
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. Hash-based deduplication means
* repeat connections with the same image are a no-op.
* Optional.
*/
avatar?: {
url?: string;
base64?: string;
};
/**
* Fine-grained upload constraints and callbacks.
* Optional — sensible defaults are applied for all sub-fields.
*/
upload?: UploadConfig;
}PersistStorage
Supports both synchronous (localStorage) and asynchronous (AsyncStorage) storage backends.
interface PersistStorage {
getItem(key: string): string | null | Promise<string | null>;
setItem(key: string, value: string): void | Promise<void>;
removeItem(key: string): void | Promise<void>;
}Web (localStorage):
const persistStorage: PersistStorage = {
getItem: (key) => localStorage.getItem(key),
setItem: (key, value) => localStorage.setItem(key, value),
removeItem: (key) => localStorage.removeItem(key),
};React Native (AsyncStorage):
import AsyncStorage from '@react-native-async-storage/async-storage';
const persistStorage: PersistStorage = {
getItem: (key) => AsyncStorage.getItem(key),
setItem: (key, value) => AsyncStorage.setItem(key, value),
removeItem: (key) => AsyncStorage.removeItem(key),
};PlatformUploadFn
The core requests a presigned URL from the server, then hands the presigned response and the local file descriptor to this function. The function is responsible for the actual HTTP upload and for calling onProgress with a 0–1 fraction.
type PlatformUploadFn = (
presigned: PresignedUrlResponse,
file: UploadableFile,
onProgress?: (pct: number) => void,
) => Promise<void>;Web implementation (XHR):
const platformUploadFn: PlatformUploadFn = (presigned, file, onProgress) =>
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(presigned.method, presigned.uploadUrl);
Object.entries(presigned.headers).forEach(([k, v]) => xhr.setRequestHeader(k, v));
xhr.upload.onprogress = (e) => onProgress?.(e.loaded / e.total);
xhr.onload = () => (xhr.status < 400 ? resolve() : reject(new Error(`${xhr.status}`)));
xhr.onerror = () => reject(new Error('Network error'));
// For PUT: send the raw blob. For POST (S3-style): send FormData with fields.
if (presigned.method === 'PUT') {
fetch(file.uri)
.then((r) => r.blob())
.then((blob) => xhr.send(blob));
} else {
const fd = new FormData();
Object.entries(presigned.fields ?? {}).forEach(([k, v]) => fd.append(k, v));
fetch(file.uri)
.then((r) => r.blob())
.then((blob) => { fd.append('file', blob, file.name); xhr.send(fd); });
}
});React Native implementation (fetch):
const platformUploadFn: PlatformUploadFn = async (presigned, file, onProgress) => {
onProgress?.(0);
let body: any;
if (presigned.method === 'POST' && presigned.fields) {
// S3 and local storage — multipart FormData with signed policy fields
const fd = new FormData();
Object.entries(presigned.fields).forEach(([k, v]) => fd.append(k, v as string));
fd.append('file', { uri: file.uri, name: file.name, type: file.type } as any);
body = fd;
} else {
// Azure Blob Storage — raw body PUT (no FormData API available on Azure)
body = { uri: file.uri, name: file.name, type: file.type } as any;
}
const res = await fetch(presigned.uploadUrl, { method: presigned.method, headers: presigned.headers, body });
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
onProgress?.(1);
};UploadConfig
interface UploadConfig {
/**
* File size limits in MB. Pass a single number to apply uniformly,
* or per-type limits. Defaults: image 5, video 25, audio 10, document 10.
*/
maxFileSizeMB?: number | {
image?: number;
video?: number;
audio?: number;
document?: number;
default?: number;
};
/** Max attachments per message. Default: 10. */
maxFilesPerMessage?: number;
/** Restrict which file categories are allowed. Default: all four. */
allowedTypes?: Array<'image' | 'video' | 'audio' | 'document'>;
/** Called when a file fails local validation or the upload itself fails. */
onUploadError?: (file: UploadableFile, error: Error) => void;
/** Called with 0–100 aggregate progress during a batch upload. */
onProgress?: (progress: number) => void;
}Example with per-type limits:
const config: AntzChatConfig = {
apiUrl: 'https://api.yourapp.com/api/v1',
persistStorage,
platformUploadFn,
upload: {
maxFileSizeMB: { image: 10, video: 50, audio: 20, document: 25 },
maxFilesPerMessage: 5,
allowedTypes: ['image', 'document'],
onUploadError: (file, err) => console.error(`Failed to upload ${file.name}:`, err),
onProgress: (pct) => setUploadProgress(pct),
},
};Non-Builtin Authentication (antz / external / wso2)
When the Antz Chat server runs in antz, external, or wso2 mode, authentication is handled by an external system. The SDK must pass three things on every request:
| Config field | HTTP header | When required |
|---|---|---|
| authToken or authProvider | Authorization: Bearer <token> | Always |
| userId | x-user-id | Non-builtin modes |
| tenantId | X-Tenant-ID | Non-builtin modes |
const client = new AntzChatClient({
apiUrl: 'https://api.yourapp.com/api/v1',
persistStorage,
platformUploadFn,
authToken: 'jwt-from-your-auth-system',
userId: '58', // the user's ID in your external system
tenantId: '11', // your tenant/zoo/org ID
});Avatar
There are three ways to set or update a user's avatar:
1. Config — on init (any mode)
Pass the avatar when constructing AntzChatClient. The server fetches the URL (or decodes the base64), validates, uploads to its own storage, and deduplicates by SHA-256 hash — repeat connections are a no-op.
const client = new AntzChatClient({
// ...
avatar: {
url: 'https://cdn.yoursystem.com/avatars/user-58.jpg',
// OR
// base64: 'data:image/jpeg;base64,...',
},
});2. client.auth.syncAvatar() — post-init update (any mode)
Call this any time after init to push a new avatar from a URL or base64 string. Use this when the avatar changes after the client is already running — for example when the user updates their profile in your external system.
// From a URL
await client.auth.syncAvatar({ url: 'https://cdn.example.com/new-avatar.jpg' });
// From base64
await client.auth.syncAvatar({ base64: 'data:image/jpeg;base64,...' });3. client.auth.uploadAvatar() — file upload (builtin mode only)
Use when the user picks a file from their device. Sends as multipart to PUT /users/me/avatar.
const file = input.files[0]; // File from <input type="file">
const { avatarUrl } = await client.auth.uploadAvatar(file);Which to use:
| Scenario | Method |
|---|---|
| Avatar known at init and won't change | AntzChatConfig.avatar |
| Avatar URL changes at runtime | client.auth.syncAvatar({ url }) |
| User picks a file to upload (builtin mode) | client.auth.uploadAvatar(file) |
Supported formats: JPEG, PNG, GIF, WebP — max 5 MB by default. Server-side limit is configurable:
AVATAR_MAX_SIZE=5242880 # 5 MB default — change to any value in bytesIf no avatar is provided — nothing breaks. The user simply has no profile picture until one is set.
API Reference
AntzChatClient (Headless)
A single class that initializes the API client, auth store, and socket in one shot. Use this when you need direct programmatic control and don't want to wire the internals yourself.
class AntzChatClient {
readonly auth: typeof authApi;
readonly messages: typeof messagesApi;
readonly conversations: typeof conversationsApi;
readonly storage: typeof storageApi;
readonly socket: {
emit: typeof socketEmit;
on(event: string, handler: (...args: unknown[]) => void): void;
off(event: string, handler: (...args: unknown[]) => void): void;
};
constructor(config: AntzChatConfig);
/** Connect the Socket.IO client. Resolves when the connection is established. */
connect(): Promise<void>;
/** Disconnect the Socket.IO client and clear the socket singleton. */
disconnect(): void;
/**
* High-level batch upload. Requests presigned URLs, delegates binary upload
* to the configured platformUploadFn, and confirms each upload with the server.
*/
uploadFiles(files: UploadableFile[], conversationId?: string): Promise<BatchUploadResult>;
/**
* Upload or replace the group icon (admin only).
* Internally calls uploadFiles() to upload the file, then sets the icon on the conversation.
* Same presigned URL pipeline as message attachments — platformUploadFn is handled automatically.
*/
uploadIcon(conversationId: string, file: UploadableFile): Promise<Conversation>;
/**
* Remove the group icon (admin only).
* Deletes the asset from storage and clears iconMeta on the conversation.
* Returns the updated conversation with iconUrl: undefined.
*/
removeIcon(conversationId: string): Promise<Conversation>;
}Usage:
const client = new AntzChatClient(config);
// Option A — SDK manages auth
await client.auth.login({ email: '[email protected]', password: 'secret' });
// Option B — pre-authenticated token in config
// const client = new AntzChatClient({ ...config, authToken: 'eyJ...' });
await client.connect();
// Listen to socket events
client.socket.on('new_message', (evt: NewMessageEvent) => handleMessage(evt));
// Emit socket events
client.socket.emit.joinRoom('conv-abc');
await client.socket.emit.sendMessage({ conversationId: 'conv-abc', text: 'Hi', tempId: 'tmp-1' });
// REST calls
const convs = await client.conversations.list(); // returns all conversations
const msgs = await client.messages.list('conv-abc', { limit: 50 });
// Upload files
const result = await client.uploadFiles(
[{ uri: 'blob:http://...', name: 'photo.jpg', type: 'image/jpeg', size: 204800 }],
'conv-abc',
);
console.log('Uploaded:', result.successful);
console.log('Failed:', result.failed);
client.disconnect();Auth API (authApi)
import { authApi } from '@antzsoft/chat-core';| Method | Signature | Description |
|---|---|---|
| login | (credentials: LoginCredentials) => Promise<AuthResponse> | Authenticate with email + password. Returns user and tokens. |
| register | (data: RegisterData) => Promise<AuthResponse> | Create a new account. |
| refresh | (refreshToken: string) => Promise<AuthTokens> | Exchange a refresh token for new tokens. The HTTP client handles this automatically on 401 — call manually only if needed. |
| logout | (refreshToken?: string) => Promise<void> | Invalidate the current session. |
| logoutAll | () => Promise<void> | Invalidate all sessions for the current user. |
| getMe | () => Promise<User> | Fetch the current user's profile. |
| uploadAvatar | (file: File \| Blob, mimeType?: string) => Promise<{ avatarUrl: string }> | Multipart avatar upload for builtin auth mode. |
| syncAvatar | (source: { url?: string; base64?: string }) => Promise<{ avatarUrl: string }> | Sync avatar from a URL or base64 string — for non-builtin modes or post-init updates. |
// Login
const { user, tokens } = await authApi.login({ email: '[email protected]', password: 'secret' });
// Register
const { user } = await authApi.register({
email: '[email protected]',
password: 'hunter2',
username: 'newuser',
firstName: 'New',
lastName: 'User',
tenantId: 'tenant-xyz',
});
// Logout
await authApi.logout(tokens.refreshToken);
// Upload avatar (builtin mode — user picks a file)
const { avatarUrl } = await authApi.uploadAvatar(file);
// Sync avatar from URL (any mode — use when avatar changes after init)
const { avatarUrl } = await authApi.syncAvatar({ url: 'https://cdn.example.com/avatar.jpg' });
// Sync avatar from base64 (any mode)
const { avatarUrl } = await authApi.syncAvatar({ base64: 'data:image/jpeg;base64,...' });Messages API (messagesApi)
import { messagesApi } from '@antzsoft/chat-core';| Method | Signature | Description |
|---|---|---|
| list | (conversationId: string, params?: ListMessagesParams) => Promise<CursorPaginatedResponse<Message>> | Fetch messages with cursor pagination. |
| get | (messageId: string) => Promise<Message> | Fetch a single message. |
| send | (conversationId: string, payload: SendData) => Promise<Message> | Send a message via REST (use socketEmit.sendMessage for real-time delivery). Pass payload.tempId and reuse the SAME value on retry to make a retry-after-timeout safe — see "Retry safety" below. |
| update | (messageId: string, text: string) => Promise<Message> | Edit message text. |
| delete | (messageId: string) => Promise<void> | Delete a message for everyone (own message within window, or admin). |
| deleteForMe | (messageId: string) => Promise<void> | Hide a message for the current user only — other participants are unaffected. |
| addReaction | (messageId: string, emoji: string) => Promise<Message> | Add an emoji reaction. |
| removeReaction | (messageId: string, emoji: string) => Promise<Message> | Remove an emoji reaction. |
| star | (messageId: string) => Promise<void> | Star a message. |
| unstar | (messageId: string) => Promise<void> | Unstar a message. |
| getStarred | (params?: { page?: number; limit?: number; conversationId?: string }) => Promise<PaginatedResponse<Message>> | List starred messages. |
| search | (params: SearchParams) => Promise<PaginatedResponse<Message>> | Full-text message search. |
| getLastRead | (conversationId: string) => Promise<{ lastReadMessageId: string \| null; lastReadAt: string \| null }> | Fetch the current user's last-read pointer for a conversation. Use on initial load; after that the store is kept live by socket events. |
| markAsRead | (conversationId: string, messageId?: string) => Promise<void> | Mark messages as read via REST. |
| getReceipts | (messageId: string) => Promise<MessageReceiptsResponse> | Fetch per-user read and delivery receipts for a single message, with resolved user profiles (name, avatar). Use as the initial load for a message info / "Read by" detail screen. |
| getReactions | (messageId: string) => Promise<MessageReactionsResponse> | Fetch the full reaction breakdown for a single message — grouped by emoji with per-user details and didIReact. Use for a reactions detail sheet. |
| pin | (messageId: string) => Promise<Message> | Pin a message. |
| unpin | (messageId: string) => Promise<Message> | Unpin a message. |
| getPinned | (conversationId: string) => Promise<Message[]> | List pinned messages in a conversation. |
| forward | (messageId: string, targetConversationIds: string[]) => Promise<ForwardResult[]> | Forward a message into one or more conversations (v1.4.2+). Max MAX_FORWARD_TARGETS (5) targets per call, reduced to 1 if the source message has forwardDepth >= 5. Each target is independent — check success/error per entry in the returned array. No hard cap on forward count. |
Delete permissions —
delete()(for everyone) requires either: the message belongs to the current user AND was sent within the delete window, OR the current user is a group admin. The server default window is 216,000 s (60 hours) whenconversation.settings.messageConfig.deleteWindowSecondsis not set. DMs have no admin role — only the sender can delete for everyone in a DM.deleteForMe()is always allowed for any message. See the integration guide — Edit & Delete section for a ready-to-usegetDeleteOptions()helper.
interface ListMessagesParams {
cursor?: string; // Opaque cursor for pagination
limit?: number; // Default decided by server
direction?: 'before' | 'after';
}
interface SendData {
text?: string;
attachments?: SendMessageAttachment[];
replyTo?: string; // messageId of the message being replied to
tempId?: string; // Client-generated idempotency key — see "Retry safety" below. NOT auto-generated if omitted.
mentions?: string[]; // Mentioned userIds ('all' for @all); derived from @[name](id) tokens in text
}
interface SearchParams {
query: string;
conversationId?: string;
page?: number;
limit?: number;
}// Cursor-paginated message history
const page1 = await messagesApi.list('conv-abc', { limit: 50 });
if (page1.meta.hasMore && page1.meta.nextCursor) {
const page2 = await messagesApi.list('conv-abc', {
cursor: page1.meta.nextCursor,
direction: 'before',
limit: 50,
});
}
// Send with a reply reference
await messagesApi.send('conv-abc', {
text: 'Good point!',
replyTo: 'msg-456',
tempId: crypto.randomUUID(),
});
// Search
const results = await messagesApi.search({ query: 'deployment', conversationId: 'conv-abc' });Message Send Retry Safety (v1.4.4+)
If messagesApi.send()/socketEmit.sendMessage() times out client-side, you don't know whether the server actually created the message before the response was lost. Retrying blind can create a duplicate. tempId fixes this — the server checks (conversationId, senderId, tempId) before creating a message, on both the REST send path and the socket path:
- Generate one
tempIdper send attempt (a real UUID —generateUUIDfrom'@antzsoft/chat-core/internal', orcrypto.randomUUID()). - Reuse the exact same
tempIdif you retry that same send. Never mint a new one for a retry — only for a genuinely new message. - A retry with a matching
tempIdreturns the already-created message instead of creating a duplicate. On the socket path this is fully silent to everyone else — the retrying client still gets itsmessage_ack(so its optimistic bubble reconciles), but no secondnew_messageis broadcast to the room and no second push notification fires, since the original successful attempt already delivered both. - Unlike
forward(),send()does not auto-generate atempIdif you omit it — omitting it, or generating a fresh one on every retry, gets no dedup protection and reproduces the exact pre-1.4.4 behavior. socketEmit.sendMessage'sSendMessagePayload.tempIdwas always a required field — every client already sends one on every call. This release is what makes the server actually act on it; no wire-format change.
useChat().retrySendMessage(failedMessageId) (both UI SDKs) does all of this correctly for you: it resends the exact original payload (same tempId, same already-uploaded attachment fileIds — no re-upload) for a message whose deliveryStatus is 'failed'. The built-in MessageItem component renders a tappable "Retry" on the failed-delivery indicator and a "Retry" entry in the message action menu, both wired to this — no extra code needed if you're using the prebuilt UI. If you're driving messagesApi/socketEmit directly (headless usage), you own remembering the tempId per attempt yourself.
Forward Message (v1.4.2+)
Forwarding creates an independent copy of a message in each target conversation — never a shared row. Each copy gets its own ID, sequence number, read receipts, and delete lifecycle; deleting one copy never affects another, and none of them affect the original.
| Field / method | Type | Description |
|---|---|---|
| messagesApi.forward(messageId, targetConversationIds, attachmentIds?, tempId?) | Promise<ForwardResult[]> | Forwards into up to MAX_FORWARD_TARGETS (5) conversations in one call — fewer if the source message is highly forwarded, see Limits below. Pass attachmentIds to forward only a subset of the source message's attachments (e.g. one image out of a multi-image message); omit it to forward the whole message unchanged. An ID not on the source message is ignored. tempId makes retries safe — see "Retry safety" below; auto-generated if omitted. Uses the socket transport when connected, REST otherwise — see "Transport" below. |
| socketEmit.forwardMessage(payload: ForwardMessagePayload) | Promise<ForwardAckPayload> | Lower-level socket-only entry point that messagesApi.forward() uses internally when a socket is connected. Most consumers should call messagesApi.forward() instead so REST fallback is automatic. |
| Message.forwardedFrom | MessageForwardReference \| undefined | Present on a message created via forwarding. |
| MAX_FORWARD_TARGETS | number | 5 — matches the server's normal-case cap. |
| HIGHLY_FORWARDED_DEPTH_THRESHOLD | number | 5 — matches the server's forwardDepth value that triggers BOTH the "Forwarded many times" label and the reduced fan-out cap. Use this for the label instead of hardcoding a number. |
Limits:
| Limit | Value | Behavior when exceeded |
|---|---|---|
| Targets per call (normal) | 5 (MAX_FORWARD_TARGETS) | Client: ForwardPicker disables further selection. Server: request rejected with a validation error if bypassed. |
| Targets per call — highly-forwarded content (forwardDepth >= 5) | 1 | Server silently truncates to the first target and reports every dropped target back as an explicit { success: false, error } entry in ForwardResult[] — never a validation error, never a fully blocked forward. Matches WhatsApp's actual behavior: reduced fan-out, not a forwarding ban. |
| Forward API rate limit | 20 calls/min per user | 429 if exceeded (server-side @Throttle, not currently surfaced as a distinct client-side pre-check). |
| Forward-count hard cap | None | A message can be forwarded any number of times — there is no "forwarded too many times, blocked" state. Only fan-out width is reduced at high forwardDepth; forwardDepth itself only ever drives the "Forwarded many times" label. |
Because dropped targets from the highly-forwarded cap come back as regular ForwardResult failures, existing "Forwarded to N/M chats" UI (see the example below) needs no special-casing to handle it correctly.
interface MessageForwardReference {
originalMessageId: string; // one hop back only — never the root of a longer chain
originalConversationId: string; // one hop back only
originalSenderId: string; // one hop back only — NOT the original author past 1 hop
forwardDepth: number; // accumulates across the whole chain
}
interface ForwardResult {
conversationId: string;
success: boolean;
message?: Message; // present when success is true
error?: string; // present when success is false
}const results = await messagesApi.forward(messageId, [convA, convB, convC]);
const failed = results.filter(r => !r.success);
if (failed.length > 0) {
console.warn(`Forward failed for ${failed.length}/${results.length} conversations`, failed);
}
// Forward only one attachment out of a multi-attachment message (e.g. image 2 of 3)
await messagesApi.forward(messageId, [convA], [message.content.attachments![1].id]);Where forwardedFrom is populated. Every place a Message object is returned — messagesApi.list/get, the new_message socket event, syncApi (reconnect/resync), and REST list/search/starred/pinned — always includes forwardedFrom when present. conversation_updated's lastMessage field also includes it (a conversation-list preview showing a forwarded message can render the "Forwarded" label without a separate message fetch). If forwardDepth ever reads as absent/0 where you expected a value, that's a bug to report, not expected behavior — it should never be silently missing on any of these paths.
Why only one hop of lineage is kept. forwardedFrom always points at the immediate message being forwarded, never the ultimate original — matching WhatsApp: if you forward a message that was itself already forwarded, the new copy's originalMessageId/originalSenderId reference that intermediate copy, not the very first message in the chain. This is intentional: it keeps the true original author untraceable after more than one hop (privacy), while forwardDepth still accumulates across the whole chain. forwardDepth drives two things, both purely about slowing spread, never about blocking it: the "Forwarded many times" badge, and server-side, the reduced 1-target fan-out cap — both gated on the SAME threshold, HIGHLY_FORWARDED_DEPTH_THRESHOLD (currently 5, exported from the package root). Use that constant for the label rather than hardcoding a number, so the two never drift apart again. There is no API to walk a message's full forward history — only the immediate parent is ever resolvable, and there is no forward-count ceiling that ever blocks forwarding outright.
Attachments are never re-uploaded. A forwarded attachment reuses the same underlying storage object as the source message — only the message row referencing it is new. This is transparent to SDK consumers; forward() handles it server-side.
Rendering: show a "Forwarded" (or "Forwarded many times" at forwardDepth >= HIGHLY_FORWARDED_DEPTH_THRESHOLD) label when message.forwardedFrom is present — do not render a sender name or content preview for it (unlike replyTo), since the message's own content already holds what was forwarded. The built-in MessageItem component in both @antzsoft/chat-web-sdk and @antzsoft/chat-rn-sdk already renders this label using the exported constant and exposes a "Forward" action in the message menu — no extra wiring needed if you're using the prebuilt UI.
Transport (v1.4.4+). forward() sends over the forward_message socket event (acked via the standard socket.io callback — the same request/response ack pattern as update_message/pin_message/etc., not a separate broadcast event) whenever a socket is connected, and transparently falls back to REST otherwise — you never call the socket path directly; messagesApi.forward() picks it for you. Either way it runs through the exact same server-side MessagesService.forward() code path, so broadcast/conversation_updated/push-notification behavior is identical regardless of which transport was actually used. There is still no built-in automatic retry on timeout — if a call times out client-side, you decide whether/when to retry — but unlike a plain REST call, a client that's connected gets the lower-latency socket path without any code change.
Retry safety (v1.4.4+). The tempId parameter is what makes a retry safe, on either transport:
- Generate one
tempIdper forward action (one user tap on "Forward," covering all its target conversations) — not one per target. - Reuse the exact same
tempIdif you retry that same action (e.g. the user taps "Forward" again after a partial failure). Never mint a new one for a retry — only when the user starts a genuinely new forward. - The server checks
(targetConversationId, senderId, tempId)— scoped per target, since one forward action can create up to 5 messages, one per target — before creating anything. A retry with a matchingtempIdreturns the message that already exists for that target instead of creating a duplicate; targets that failed the first time are retried normally. - If you omit
tempId, one is auto-generated per call — meaning a retry without passing the same value back gets no dedup protection and may create a duplicate for any target that actually succeeded before the failure was reported. The built-inForwardPickercomponent in both@antzsoft/chat-web-sdkand@antzsoft/chat-rn-sdkalready does this correctly (onetempIdper picker session, reused across retries) — no extra wiring needed if you're using the prebuilt UI. - Regular message sends (
socketEmit.sendMessage/messagesApi.send()) get this same protection — see "Retry safety" above.
Jump to first unread message
Use direction: 'after' with the user's lastReadMessageId as the cursor to fetch only the unread messages. This powers a scroll-to-first-unread experience with an "↑ Unread messages" divider.
import { messagesApi, useChatStore } from '@antzsoft/chat-core';
// 1. Get the last-read pointer and seed the store
const { lastReadMessageId, lastReadAt } = await messagesApi.getLastRead(conversationId);
if (lastReadMessageId && lastReadAt) {
useChatStore.getState().setLastRead(conversationId, lastReadMessageId, lastReadAt);
// 2. Fetch all messages AFTER the last-read message — these are unread
const { data: unreadMessages, meta } = await messagesApi.list(conversationId, {
cursor: lastReadMessageId,
direction: 'after',
limit: 50,
});
// unreadMessages[0] is the first unread — scroll the list to this item
// meta.hasMore = true means there are more than 50 unread messages
if (unreadMessages.length > 0) {
scrollToMessage(unreadMessages[0].id);
}
} else {
// No prior read state — load latest messages normally
const { data: messages } = await messagesApi.list(conversationId, { limit: 30 });
}Render the divider in your message list by checking useChatStore.lastRead[conversationId]:
const lastRead = useChatStore((s) => s.lastRead[conversationId]);
function MessageRow({ message, prevMessage }) {
// Insert divider between the last-read message and the next one
const isFirstUnread = lastRead && prevMessage?.id === lastRead.messageId;
return (
<>
{isFirstUnread && <UnreadDivider />}
<MessageBubble message={message} />
</>
);
}After the user reads the messages, call socketEmit.markRead(conversationId) (see Read Receipts) to update the server and broadcast the receipt to other participants.
Mentions (@antzsoft/chat-core utilities)
Group @mentions let a user tag specific members (or everyone via @all). A mentioned
user is notified even if they muted the group (the mention pierces mute), while
everyone else follows normal mute rules. @all is server-gated to group admins.
Storage model. A mention is stored inline in the message text as a self-describing
token — @[DisplayName](userId), and @[all](all) for @all — plus a flat
mentions: string[] array on the message (denormalized userIds, 'all' for @all).
There are no character offsets: the token is self-locating and survives edits, and
the embedded name is a fallback for rendering (the current name is resolved live).
import {
parseMentions,
buildMentionText,
renderMentionParts,
extractMentionIds,
isMentionAll,
MENTION_ALL_ID, // 'all'
} from '@antzsoft/chat-core';| Function | Signature | Use |
|---|---|---|
| buildMentionText | (segments: MentionSegment[]) => { text; mentions } | Composer: turn picked members into token text + the id array to send |
| parseMentions | (text) => ParsedMention[] | Locate @[name](id) tokens (id, displayName, start, end) |
| renderMentionParts | (text, resolveName?) => MentionPart[] | Split text into ordered text/mention parts for rendering |
| extractMentionIds | (text) => string[] | Re-derive the id array from tokens (after an edit) |
| isMentionAll | (mentions?) => boolean | True when the list targets everyone |
// Compose — from a member picked in your @-autocomplete
const { text, mentions } = buildMentionText([
'Hey ', { id: 'a1b2…', displayName: 'Alice' }, ', please review',
]);
// text → "Hey @[Alice](a1b2…), please review"
// mentions → ["a1b2…"]
await messagesApi.send(conversationId, { text, mentions, tempId });
// Render — resolve the CURRENT name from your directory; fall back to the token name
const parts = renderMentionParts(message.content.text, (id, fallbackName) =>
participants.find((p) => p.userId === id)?.user?.displayName ?? fallbackName,
);
// parts: [{type:'text', text:'Hey '}, {type:'mention', id, displayName:'Alice'}, …]Name resolution (rename / departed member). Always prefer the live-resolved name so renames show correctly; use the token's embedded name only when the id can't be resolved (a member who left, or a client without a directory). This mirrors how WhatsApp/Slack resolve mention names at render time.
Picking members. Source the @-autocomplete from conversationsApi.getMembers(conversationId)
(active members only — never pass filter) or the already-normalized
conversation.participants. See the web/RN SDKs for a ready-made composer + renderer.
Conversations API (conversationsApi)
import { conversationsApi } from '@antzsoft/chat-core';| Method | Signature | Description |
|---|---|---|
| list | (params?: ConversationListParams) => Promise<PaginatedResponse<Conversation>> | List conversations with optional server-side filters. |
| get | (conversationId: string) => Promise<Conversation> | Fetch a single conversation. |
| createGroup | (data: CreateGroupData) => Promise<Conversation> | Create a group conversation. |
| createDirect | (data: CreateDirectData) => Promise<Conversation> | Start or retrieve a direct conversation with another user. |
| update | (conversationId: string, data: UpdateConversationData) => Promise<Conversation> | Update group name or description. |
| uploadIcon | (conversationId: string, fileId: string) => Promise<Conversation> | Set the group icon from an already-uploaded file (admin only). Call client.uploadFiles() first to get the fileId, then pass it here. Server copies storageKey into conversation.iconMeta, deletes the chat_files record, and returns the conversation with a fresh iconUrl. |
| removeIcon | (conversationId: string) => Promise<Conversation> | Remove the group icon (admin only). Deletes the asset from storage, clears iconMeta on the conversation, and returns the updated conversation with iconUrl: undefined. Non-admins receive 403 Forbidden. |
| delete | (conversationId: string) => Promise<void> | Hide a conversation from the caller's list. Works for any participant (active or inactive) on both DMs and groups — no admin role required. For DMs this is "Delete Chat"; for groups this is "Delete Group" (after already exiting). Other participants are completely unaffected. |
| addParticipants | (conversationId: string, userIds: string[], role?: 'admin' \| 'member') => Promise<Conversation> | Add one or more participants. role defaults to 'member'. Previously removed members who are re-added always receive the specified role — a former admin re-added without role: 'admin' comes back as a member. Message visibility on re-add depends on whether the user previously deleted the conversation (see Message History & Re-add). |
| removeParticipant | (conversationId: string, userId: string) => Promise<Conversation> | Remove a participant (admin only). |
| updateParticipantRole | (conversationId: string, userId: string, role: 'admin' \| 'member') => Promise<Conversation> | Promote or demote a participant. |
| mute | (conversationId: string, mutedUntil?: string) => Promise<void> | Mute notifications. Pass an ISO date string to mute until a specific time. |
| unmute | (conversationId: string) => Promise<void> | Unmute a conversation. |
| pin | (conversationId: string) => Promise<void> | Pin a conversation to the top of the list. Max 5 pins — server returns 400 if the limit is reached. |
| unpin | (conversationId: string) => Promise<void> | Unpin a conversation. |
| markUnread | (conversationId: string) => Promise<void> | Manually flag a conversation as unread (Conversation.isManuallyUnread becomes true), independent of unreadCount. |
| markRead | (conversationId: string) => Promise<void> | Clear the manual unread flag. Also cleared automatically whenever the conversation is opened/read through the normal mark-as-read flow. |
| leave | (conversationId: string, andDelete?: boolean) => Promise<void> | Leave a group conversation. Pass andDelete: true to also hide it from the caller's list in one atomic operation ("Exit and Delete"). When the last admin calls leave(), the server automatically promotes the longest-standing active member to admin before completing the exit — no client action required. |
| getMembers | (conversationId: string) => Promise<User[]> | Fetch full user profiles for all participants. |
interface ConversationListParams {
/** Omit both page and limit to receive all results in one response */
page?: number;
limit?: number;
/** Filter by conversation type */
type?: 'direct' | 'group';
/** Only pinned (true) or unpinned (false) conversations */
isPinned?: boolean;
/** Only muted (true) or unmuted (false) conversations */
isMuted?: boolean;
/** Only conversations with at least one unread message */
hasUnread?: boolean;
/** Search by group name / description — uses the server-side text index */
search?: string;
/** Filter by the current user's role in the conversation */
role?: 'admin' | 'member';
/** Filter by whether the last message has attachments */
hasAttachments?: boolean;
/** Filter by last message attachment type */
attachmentType?: 'image' | 'video' | 'document' | 'audio';
/** Filter by notification enabled/disabled for the current user */
notificationsEnabled?: boolean;
}interface CreateGroupData {
name: string;
description?: string;
participantIds: string[];
}
interface CreateDirectData {
userId: string;
}
interface UpdateConversationData {
name?: string;
description?: string;
}// All conversations (no page/limit = server returns everything)
const { data } = await conversationsApi.list();
// Filter by type
const groups = await conversationsApi.list({ type: 'group' });
const dms = await conversationsApi.list({ type: 'direct' });
// Filter pinned / muted / unread
const pinned = await conversationsApi.list({ isPinned: true });
const muted = await conversationsApi.list({ isMuted: true });
const unread = await conversationsApi.list({ hasUnread: true });
// Text search (uses server-side MongoDB text index on name + description)
const results = await conversationsApi.list({ search: 'design' });
// Filter by current user's role
const adminConvs = await conversationsApi.list({ role: 'admin' });
// Combine filters — pinned group conversations with unread messages
const urgent = await conversationsApi.list({
type: 'group',
isPinned: true,
hasUnread: true,
});
// Explicit pagination (pass page + limit to opt in)
const page1 = await conversationsApi.list({ page: 1, limit: 20 });// Create a group
const group = await conversationsApi.createGroup({
name: 'Engineering',
participantIds: ['user-a', 'user-b', 'user-c'],
});
// Start a DM
const dm = await conversationsApi.createDirect({ userId: 'user-b' });
// ── Group icon ────────────────────────────────────────────────────────────────
// Upload or replace the group icon (admin only).
// Uses the SAME presigned URL pipeline as message attachments — platformUploadFn
// is handled automatically from config, you never pass it explicitly.
// Always call AFTER createGroup — the group must exist first.
// Using AntzChatClient (headless) — one call, same as client.uploadFiles()
const updated = await client.uploadIcon(group.id, {
uri: 'blob:http://...', // URL.createObjectURL(file) on web, file URI on RN
name: 'icon.jpg',
type: 'image/jpeg',
size: file.size,
});
console.log(updated.iconUrl); // fresh signed URL, regenerated on every response
// What client.uploadIcon() does internally (same as attachment upload):
// 1. uploadFiles([file], conversationId)
// → POST /storage/presigned-url (creates temp chat_files record)
// → platformUploadFn uploads binary directly to S3/Azure/local:
// S3 / local: multipart POST with signed policy fields (FormData)
// Azure: raw buffer PUT with SAS token in URL
// → POST /storage/confirm/:fileId (marks chat_files active)
// 2. conversationsApi.uploadIcon(conversationId, fileId)
// → PUT /conversations/:id/icon { fileId }
// Server: validateAdmin() → copy storageKey into conversation.iconMeta
// → delete chat_files record (it was only a transport vehicle)
// → return conversation with fresh iconUrl
// iconUrl behaviour:
// - Never stored in DB — regenerated fresh from iconMeta.storageKey on every response
// - Previous icon deleted from storage automatically on replace
// - Non-admins get 403 Forbidden
// Remove the group icon (admin only).
// Deletes the asset from storage and clears iconMeta. Returns conversation with iconUrl: undefined.
const noIcon = await conversationsApi.removeIcon(group.id);
// noIcon.iconUrl === undefined
// Add members (default role: member)
await conversationsApi.addParticipants(group.id, ['user-d', 'user-e']);
// Add members as admins
await conversationsApi.addParticipants(group.id, ['user-f'], 'admin');
// Mute for 8 hours
const mutedUntil = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString();
await conversationsApi.mute(group.id, mutedUntil);
// ── Unread counts ──────────────────────────────────────────────────────────
// Total unread across all conversations + per-conversation breakdown
const summary = await conversationsApi.getUnreadSummary();
// summary.totalUnread → 12
// summary.byConversation → [{ conversationId, unreadCount }, ...]
// Unread count for one specific conversation
const { unreadCount } = await conversationsApi.getUnreadCount(conversationId);When to call unread APIs vs relying on socket
The socket keeps unread counts live while the app is connected. The REST APIs are the source of truth for everything else:
| Situation | What to do |
|---|---|
| App cold start | Call getUnreadSummary() — hydrate local state before socket connects |
| App comes to foreground | Call getUnreadSummary() — catch up on anything received while socket was down |
| Socket reconnects after drop | Call getUnreadSummary() — reconcile any drift during the outage |
| Push notification opens a specific chat | Call getUnreadCount(conversationId) — refresh just that conversation |
| User is actively chatting (socket connected) | Use unreadCount from conversationsApi.list() or the conversation_updated socket event — no need to poll REST |
Socket events for real-time unread updates
import { tryGetSocket } from '@antzsoft/chat-core';
const socket = tryGetSocket();
// Fires when a new message arrives — updates unreadCount for that conversation
socket?.on('conversation_updated', ({ conversationId, unreadCount }) => {
// unreadCount is calculated server-side from the DB — always accurate
});
// Fires when YOU mark a conversation as read — resets unreadCount to 0
// Also fires on your OTHER devices (same user, different session)
socket?.on('unread_count_changed', ({ conversationId, unreadCount }) => {
// unreadCount is 0 here — conversation was just read
});Both events are emitted to the user's private room (user:{tenantId}:{userId}) so every connected device of the same user receives them simultaneously. This is how reading on your phone automatically clears the badge on your browser tab.
Chat icon badge — complete pattern (headless / custom UI)
When building a custom UI using chat-core directly (no web/RN SDK), maintain your own unread state and update it on socket events:
import { conversationsApi, tryGetSocket } from '@antzsoft/chat-core';
// 1. Cold start — fetch accurate counts from DB
const summary = await conversationsApi.getUnreadSummary();
let totalUnread = summary.totalUnread;
updateBadge(totalUnread); // your UI function
// 2. While socket is connected — update on every event
const socket = tryGetSocket();
socket?.on('conversation_updated', ({ conversationId, unreadCount }) => {
// Server sends accurate DB count per conversation — recalculate total
summary.byConversation = summary.byConversation
.filter(c => c.conversationId !== conversationId)
.concat({ conversationId, unreadCount });
totalUnread = summary.byConversation.reduce((s, c) => s + c.unreadCount, 0);
updateBadge(totalUnread);
});
socket?.on('unread_count_changed', ({ conversationId }) => {
// User read a conversation — clear it from the map
summary.byConversation = summary.byConversation
.filter(c => c.conversationId !== conversationId);
totalUnread = summary.byConversation.reduce((s, c) => s + c.unreadCount, 0);
updateBadge(totalUnread);
});
// 3. On foreground / socket reconnect — resync from DB
async function onForeground() {
const fresh = await conversationsApi.getUnreadSummary();
totalUnread = fresh.totalUnread;
updateBadge(totalUnread);
}Using
@antzsoft/chat-web-sdkor@antzsoft/chat-rn-sdk? You don't need any of this —useConversations()handles socket subscriptions internally. Just sumconversations.reduce((s, c) => s + (c.unreadCount ?? 0), 0)and it updates automatically.
Mark as Unread (v1.4.1+)
isManuallyUnread is a separate flag from unreadCount — it lets a user re-flag a conversation they've already read so it stands out again in the list, without fabricating unread messages or moving the read-receipt cursor. This is the same "mark as unread" behavior as WhatsApp/Telegram: a dot indicator, not a count.
| Field / method | Type | Description |
|---|---|---|
| Conversation.isManuallyUnread | boolean \| undefined | true once flagged; false/absent otherwise. Independent of unreadCount — both can be true/>0 at once, or isManuallyUnread can be true while unreadCount is 0. |
| conversationsApi.markUnread(id) | Promise<void> | Sets the flag. |
| conversationsApi.markRead(id) | Promise<void> | Clears the flag. |
// Flag a fully-read conversation as unread
await conversationsApi.markUnread(conversationId);
// Clear it manually (rarely needed — see auto-clear below)
await conversationsApi.markRead(conversationId);
// Render: numbered badge takes priority; fall back to a plain dot
const conv = await conversationsApi.get(conversationId);
if ((conv.unreadCount ?? 0) > 0) {
showBadge(conv.unreadCount);
} else if (conv.isManuallyUnread) {
showDot();
}Auto-clears on read. Opening the conversation — anything that runs the normal mark-as-read flow (socket mark_read, or the REST notification-catchup path) — clears isManuallyUnread server-side automatically, same as WhatsApp. You do not need to call markRead() yourself after the user opens the chat; it's only for the explicit "un-flag without opening" action (e.g. an X button on the dot).
Live sync across devices. Unlike mute/pin (which currently only take effect on the next fetch), toggling isManuallyUnread emits a conversation_updated socket event to the caller's other connected sessions immediately:
socket?.on('conversation_updated', (conv) => {
// conv.isManuallyUnread reflects the latest state, pushed live
});Web/RN SDK hooks expose named mutations: markUnread, markRead (both SDKs) — cache updated optimistically, no manual invalidation needed. The built-in ConversationList component already renders the dot and exposes the toggle from its existing Pin/Mute menu.
Clear / Delete Chat (v1.2.6+)
conversationsApi.delete(conversationId) hides a conversation from the caller's list. Any participant can call it — no admin role required. Other participants are completely unaffected.
| Scenario | Call | Effect |
|---|---|---|
| Delete Chat (DM) | conversationsApi.delete(id) | Hides DM, wipes periods. Re-opens with new history only when other party messages. |
| Exit Group | conversationsApi.leave(id) | Caller inactive, stays in list read-only. Auto-promotes admin if needed. |
| Exit and Delete | conversationsApi.leave(id, true) | Atomic exit + hide. Periods wiped. No race window. |
| Delete Group (post-exit) | conversationsApi.delete(id) | Hides already-exited group entry. Others unaffected. |
// Delete Chat — DM
await conversationsApi.delete(dmConversationId);
// Exit Group
await conversationsApi.leave(groupId);
// Exit and Delete (atomic — one write)
await conversationsApi.leave(groupId, true);
// Listen for confirmation on the caller's own sockets
socket.on('conversation_deleted', ({ conversationId }) => {
removeFromConversationList(conversationId);
if (activeConversationId === conversationId) navigateBackToList();
});Message history after re-add: if the user left with delete() or leave(true), membership periods are wiped — only messages from the re-add point are visible. Plain leave() preserves prior history windows.
Web/RN SDK hooks expose named mutations: leaveGroup, leaveAndDeleteGroup, deleteGroup (web) / deleteConversation (RN) — cache updated optimistically, no manual invalidation needed.
Storage API (storageApi and uploadBatch)
import { storageApi, uploadBatch } from '@antzsoft/chat-core';storageApi methods:
| Method | Signature | Description |
|---|---|---|
| requestPresignedUrl | (payload: PresignedUrlRequest) => Promise<PresignedUrlResponse> | Request a single presigned upload URL. |
| requestPresignedUrlBatch | (files: PresignedUrlRequest[]) => Promise<{ urls: PresignedUrlResponse[]; errors: Array<{ filename: string; error: string }> }> | Batch presigned URL request. |
| confirmUpload | (fileId: string) => Promise<FileResponse> | Confirm a single-part upload is complete. Required after single-part uploads. Not called for chunked multipart — completeMultipartUpload handles that. |
| completeMultipartUpload | (fileId: string, uploadId: string, parts: CompletedPart[]) => Promise<FileResponse> | Complete a chunked multipart upload. Assembles parts on S3 and transitions the file record to active in one call. Called automatically by uploadBatch — only needed for manual flows. |
| getFile | (fileId: string) => Promise<FileResponse> | Fetch file metadata. |
| getFileUrl | (fileId: string, expiresIn?: number) => Promise<{ url: string; expiresAt: string }> | Get a fresh signed URL for an already-uploaded file. |
| deleteFile | (fileId: string) => Promise<void> | Delete a file. |
| getConversationFiles | (conversationId: string, params?: { page?: number; limit?: number; type?: FileType }) => Promise<PaginatedResponse<FileResponse>> | List all files shared in a conversation. |
| getMyFiles | `(params?: { page?: number; limit?: number }) => Promise<PaginatedR
