npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

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

About

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

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

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

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

Open Software & Tools

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

© 2026 – Pkg Stats / Ryan Hefner

@antzsoft/chat-core

v1.2.7

Published

Platform-agnostic core for Antz Chat — API, socket, stores, types. Works in browser, React Native (Expo or bare), and Node.js.

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.

npm version license


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-core

axios, 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, leave, manage members | | Messages | Send, edit, delete, react, star, pin, search, paginate | | 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 useChatStore Zustand store helps with this)
  • File picker integration (to produce UploadableFile objects for uploadFiles)

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 bytes

If 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). | | 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. | | 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. |

Delete permissionsdelete() (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) when conversation.settings.messageConfig.deleteWindowSeconds is 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-use getDeleteOptions() 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 ID for optimistic UI
}

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' });

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.


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. | | 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-sdk or @antzsoft/chat-rn-sdk? You don't need any of this — useConversations() handles socket subscriptions internally. Just sum conversations.reduce((s, c) => s + (c.unreadCount ?? 0), 0) and it updates automatically.

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<PaginatedResponse<FileResponse>> | List files uploaded by the current user. |

uploadBatch — high-level helper:

function uploadBatch(
  files: UploadableFile[],
  platformUploadFn: PlatformUploadFn,
  conversationId?: string,
  onProgress?: (pct: number) => void,
  platformCompressFn?: PlatformCompressFn,
  compressionConfig?: ResolvedCompressionConfig,
  platformUploadPartFn?: PlatformUploadPartFn,
): Promise<BatchUploadResult>

Handles the full upload pipeline: batch presign → parallel binary upload → confirm (or complete for multipart) each file. Returns { successful: FileResponse[]; failed: Array<{ filename: string; error: string }> }.

For files ≥ 10 MB on S3, uploadBatch automatically switches to chunked multipart upload when platformUploadPartFn is provided (the Web and RN SDKs wire this in automatically — no changes needed for integrators using those SDKs). Files below the threshold always use the existing single-part presigned POST flow.

// Manual: get a presigned URL, upload, confirm
const presigned = await storageApi.requestPresignedUrl({
  filename: 'report.pdf',
  mimeType: 'application/pdf',
  size: 512000,
  conversationId: 'conv-abc',
  // optional — stored on the server file record
  metadata: { compressed: true, originalSize: 900000, compressionAlgorithm: 'gzip' },
});
await platformUploadFn(presigned, file, (pct) => console.log(`${pct * 100}%`));
const fileRecord = await storageApi.confirmUpload(presigned.fileId);

// High-level: let uploadBatch handle everything
const result = await uploadBatch(
  files,
  platformUploadFn,
  'conv-abc',
  (pct) => setProgress(pct),
);
result.successful.forEach((f) => console.log('Uploaded:', f.url));
result.failed.forEach((f) => console.error('Failed:', f.filename, f.error));

File Compression

Compression is optional and fully backward compatible. It runs entirely client-side before the upload starts — the server receives the already-compressed file with the correct size and MIME type.

How it works

When platformCompressFn is provided and compression.enabled is true (the default when a compressor is supplied), uploadBatch compresses each file before requesting a presigned URL:

  1. Determine strategy per file (image → WebP/JPEG resize+encode, gzip → text/doc compression, skip → no-op)
  2. Run platformCompressFn(file, compressionConfig) — returns a CompressedFile
  3. If compressed result is larger than the original, the original is used instead (automatic fallback)
  4. Request presigned URL with the compressed size, MIME type, and compression metadata (compressed, originalSize, compressionAlgorithm)
  5. Upload the compressed bytes
  6. Server persists metadata.compressed, metadata.originalSize, metadata.compressionAlgorithm on the file record alongside system fields (userId, tenantId)

Strategy by file type

| File type | Strategy | Algorithm | Notes | |-----------|----------|-----------|-------| | image/jpeg, image/png, image/gif, image/bmp, image/tiff | image | webp (web) / jpeg (RN) | Resize to imageMaxDimension first | | image/webp | image | webp | Re-encode at target quality | | image/svg+xml | gzip | gzip | SVG is XML text | | text/plain, text/csv, text/markdown, application/json, text/xml, application/xml, text/yaml, application/rtf | gzip | gzip | Only when compressDocuments: true | | video/*, audio/*, application/pdf, application/zip, Office formats (.docx, .xlsx, .pptx) | skip | none | Already compressed |

Configuration

Pass compression and platformCompressFn in the config:

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

const client = new AntzChatClient({
  apiUrl: 'https://api.yourapp.com/api/v1',
  authToken: 'your-token',
  platformUploadFn: myUploadFn,
  persistStorage: myStorage,

  // Optional — omit to disable compression entirely
  platformCompressFn: myCompressFn,
  compression: {
    enabled: true,             // default: true when platformCompressFn is provided
    imageQuality: 0.85,        // 0–1, default: 0.85
    imageMaxDimension: 1920,   // longest side cap in px, default: 1920
    compressDocuments: true,   // gzip text/json/csv/xml/yaml, default: true
  },
});

Opt out completely

// Disable compression — files upload as-is
const client = new AntzChatClient({
  ...
  compression: { enabled: false },
});

Implementing platformCompressFn for Node.js

The web and RN SDKs provide their own compressors automatically. For Node.js / AntzChatClient direct usage, implement it with sharp and zlib:

import sharp from 'sharp';
import zlib from 'zlib';
import { promisify } from 'util';
import type { PlatformCompressFn } from '@antzsoft/chat-core';
import { getCompressionStrategy } from '@antzsoft/chat-core';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';

const gzip = promisify(zlib.gzip);

export const nodeCompressFn: PlatformCompressFn = async (file, options) => {
  const strategy = getCompressionStrategy(file.type, options);
  const noop = { ...file, originalSize: file.size, compressed: false, compressionAlgorithm: 'none' as const };

  if (strategy === 'image') {
    try {
      const buffer = await sharp(file.uri)
        .resize({ width: options.imageMaxDimension, height: options.imageMaxDimension, fit: 'inside', withoutEnlargement: true })
        .jpeg({ quality: Math.round(options.imageQuality * 100) })
        .toBuffer();

      if (buffer.length >= file.size) return noop;

      const tmpPath = path.join(os.tmpdir(), `${Date.now()}.jpg`);
      await fs.writeFile(tmpPath, buffer);

      return {
        uri: tmpPath,
        name: file.name.replace(/\.[^.]+$/, '.jpg'),
        type: 'image/jpeg',
        size: buffer.length,
        originalSize: file.size,
        compressed: true,
        compressionAlgorithm: 'jpeg' as const,
      };
    } catch {
      return noop;
    }
  }

  if (strategy === 'gzip') {
    try {
      const input = await fs.readFile(file.uri);
      const compressed = await gzip(input);

      if (compressed.length >= file.size) return noop;

      const tmpPath = path.join(os.tmpdir(), `${Date.now()}.gz`);
      await fs.writeFile(tmpPath, compressed);

      return {
        uri: tmpPath,
        name: file.name + '.gz',
        type: file.type,
        size: compressed.length,
        originalSize: file.size,
        compressed: true,
        compressionAlgorithm: 'gzip' as const,
      };
    } catch {
      return noop;
    }
  }

  return noop;
};

Then pass it to the client:

const client = new AntzChatClient({
  apiUrl: 'https://api.yourapp.com/api/v1',
  authToken: process.env.AUTH_TOKEN,
  platformUploadFn: myUploadFn,
  persistStorage: myStorage,
  platformCompressFn: nodeCompressFn,
  compression: { imageQuality: 0.85, imageMaxDimension: 1920 },
});

CompressedFile type

interface CompressedFile extends UploadableFile {
  originalSize: number;
  compressed: boolean;
  compressionAlgorithm: 'webp' | 'jpeg' | 'gzip' | 'none';
}

The CompressedFile extends UploadableFile — it is a drop-in replacement everywhere UploadableFile is accepted.


Devices API (devicesApi)

Used for push notification token registration. The SDK does not call this automatically — the host app is responsible for obtaining the device token from the OS and registering it.

The server stores one token document per physical device in chat_device_tokens. A single user can have multiple active tokens (phone + tablet + browser) — the server delivers push to all of them simultaneously.

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

| Method | Signature | Description | |---|---|---| | register | (payload: RegisterDeviceTokenPayload) => Promise<void> | Register or refresh a push token. Upserts by deviceId — safe to call on every app launch. | | remove | (deviceId: string) => Promise<void> | Deactivate a device token. Call on logout or when the user disables notifications. |

type RegisterDeviceTokenPayload =
  | {
      deviceId: string;        // Stable UUID — generate once on install, persist, never regenerate
      platform: 'ios' | 'android' | 'web';
      provider: 'expo' | 'fcm' | 'apns';
      token: string;           // The OS-issued push token
      userAgent?: string;
    }
  | {
      deviceId: string;
      platform: 'web';
      provider: 'web-push';
      endpoint: string;        // PushSubscription.endpoint
      p256dh: string;          // base64url key 'p256dh'
      auth: string;            // base64url key 'auth'
      userAgent?: string;
    };

The deviceId rule

deviceId must be a stable UUID that persists across app restarts. Generate it once on first install and store it in SecureStore / AsyncStorage (mobile) or localStorage (web). If you lose it, the old token becomes an orphan in the server DB and the user may receive duplicate notifications until the stale token expires.

When to call register()

Mobile: Call on every app launch after authentication. Expo and FCM can silently rotate tokens after OS upgrades or reinstalls. Calling on every launch ensures the server always has the current token — the upsert is a no-op if the token hasn't changed.

Web: Call on app init if a subscription already exists (to refresh lastUsedAt and catch silent endpoint rotation), and again when the user explicitly enables notifications.

When to call remove()

Call on logout, or when the user disables push in your settings UI. This sets the token to inactive on the server — push stops immediately for that device. Other devices belonging to the same user are unaffected.

// ── Mobile (Expo) ──────────────────────────────────────────────────────────
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import * as SecureStore from 'expo-secure-store';
import * as Crypto from 'expo-crypto';

async function getStableDeviceId(): Promise<string> {
  const existing = await SecureStore.getItemAsync('chat-device-id');
  if (existing) return existing;
  const id = Crypto.randomUUID();
  await SecureStore.setItemAsync('chat-device-id', id);
  return id;
}

// Call after every login / on every app launch after auth
async function registerPushToken(): Promise<void> {
  if (!Device.isDevice) return; // simulators cannot receive push
  const { status } = await Notifications.requestPermissionsAsync();
  if (status !== 'granted') return;
  const { data: token } = await Notifications.getExpoPushTokenAsync();
  await devicesApi.register({
    deviceId: await getStableDeviceId(),
    platform: Device.osName === 'iOS' ? 'ios' : 'android',
    provider: 'expo',
    token,
  });
}

// On logout
await devicesApi.remove(await getStableDeviceId());

// ── Mobile (FCM — direct Firebase, no Expo) ────────────────────────────────
import messaging from '@react-native-firebase/messaging';

await devicesApi.register({
  deviceId: await getStableDeviceId(),
  platform: 'android',
  provider: 'fcm',
  token: await messaging().getToken(),
});

// FCM tokens can rotate — re-register on rotation
messaging().onTokenRefresh(async (newToken) => {
  await devicesApi.register({
    deviceId: await getStableDeviceId(),
    platform: 'android',
    provider: 'fcm',
    token: newToken,
  });
});

// ── Web (VAPID) ────────────────────────────────────────────────────────────
function getStableDeviceId(): string {
  let id = localStorage.getItem('chat-device-id');
  if (!id) { id = crypto.randomUUID(); localStorage.setItem('chat-device-id', id); }
  return id;
}

function subToPayload(sub: PushSubscription, deviceId: string) {
  const b64 = (buf: ArrayBuffer | null) =>
    buf ? btoa(String.fromCharCode(...new Uint8Array(buf))) : '';
  return {
    deviceId, platform: 'web' as const, provider: 'web-push' as const,
    endpoint: sub.endpoint,
    p256dh: b64(sub.getKey('p256dh')),
    auth: b64(sub.getKey('auth')),
  };
}

// On app init after login — re-registers any existing subscription
async function syncPushSubscription(): Promise<void> {
  if (!('serviceWorker' in navigator) || Notification.permission !== 'granted') return;
  const reg = await navigator.serviceWorker.ready;
  const existing = await reg.pushManager.getSubscription();
  if (existing) await devicesApi.register(subToPayload(existing, getStableDeviceId()));
}

// When user clicks "Enable notifications"
async function enablePushNotifications(): Promise<void> {
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return;
  const reg = await navigator.serviceWorker.ready;
  const sub = await reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: YOUR_VAPID_PUBLIC_KEY,
  });
  await devicesApi.register(subToPayload(sub, getStableDeviceId()));
}

// On logout or "Disable notifications"
async function disablePushNotifications(): Promise<void> {
  const reg = await navigator.serviceWorker.ready;
  const sub = await reg.pushManager.getSubscription();
  if (sub) await sub.unsubscribe();
  await devicesApi.remove(getStableDeviceId());
}

Token lifecycle

| Event | Action | |---|---| | App launch after login (mobile) | Call register() — upsert handles token rotation automatically | | App init after login (web, subscription exists) | Call syncPushSubscription() — refreshes lastUsedAt, catches silent endpoint rotation | | User enables notifications (web) | Call enablePushNotifications() — requests permission, subscribes, registers | | User logs out (any platform) | Call remove(deviceId) | | FCM / Expo token rotates | Call register() again with new token — upsert updates the existing record | | Notification engine gets DeviceNotRegistered from Expo/FCM/VAPID | Engine auto-marks token inactive — no action needed in app |


Notification Preferences (usersApi.updatePreferences / usersApi.getPreferences)

Notification preferences are stored per user in chat_user_prefs on the server. A preferences record with all defaults is created automatically when a device token is first registered — so this API is always available after push registration.

All fields are optional on update — only send what changed. Any future preference field is added to this same collection and the same API; no new endpoints needed.

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

| Method | Description | |---|---| | getPreferences() | Fetch current preferences. Returns null if no record exists (all defaults apply). | | updatePreferences(prefs) | Partial update — only fields you pass are changed. |

// Fetch current preferences
const prefs = await usersApi.getPreferences();
// prefs is null if no record yet (all defaults apply — everything enabled, quietHours off)

// Disable reaction notifications
await usersApi.updatePreferences({ notifyOnReaction: false });

// Hide message content from notification body
await usersApi.updatePreferences({ messagePreview: false });

// Enable quiet hours (no push 23:00–07:00 IST)
await usersApi.updatePreferences({
  quietHours: { enabled: true, start: '23:00', end: '07:00', timezone: 'Asia/Kolkata' },
});

// Turn off all notifications (master switch)
await usersApi.updatePreferences({ notificationsEnabled: false });

UserPreferences type

interface UserPreferences {
  /** Master switch — false disables all push. Default: true */
  notificationsEnabled?: boolean;

  /** Play sound with notifications. Default: true */
  soundEnabled?: boolean;

  /** Show message text in notification body.
   *  false = show "New message" only (privacy mode). Default: true */
  messagePreview?: boolean;

  /** Notify when @mentioned in a group. Default: true */
  notifyOnMention?: boolean;

  /** Notify when someone reacts to your message. Default: true */
  notifyOnReaction?: boolean;

  /** Notify when added to a group. Default: true */
  notifyOnGroupInvite?: boolean;

  /** Quiet hours — no push delivered during this window. Default: disabled */
  quietHours?: {
    enabled: boolean;
    start: string;    // HH:MM — e.g. "22:00"
    end: string;      // HH:MM — e.g. "08:00"
    timezone: string; // IANA — e.g. "Asia/Kolkata"
  };
}

Adding future preferences

Add the new field to the server schema (user-prefs.schema.ts) and to the UserPreferences interface above. The same updatePreferences() / getPreferences() API handles it — no new endpoints, no new collections.


Users API (usersApi)

User data is served directly from the chat server's MongoDB shadow records — no external user-service call is made. Shadow records are kept warm by the server's background sync job (runs on startup and periodically).

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

| Method | Signature | Description | |---|---|---| | list | (params?: { query?: string; page?: number; limit?: number }) => Promise<PaginatedResponse<User>> | List users, optionally filtered by a search query. Omit page/limit for all results. | | getById | `(userId: string