@enfin/chat
v1.6.3
Published
React frontend SDK for chat platform
Readme
@enfin/chat
React frontend SDK for chat platform — components, hooks, and context provider.
Install
npm install @enfin/chat react react-dom socket.io-clientQuick Start
1. Wrap Your App with ChatProvider
import { ChatProvider } from '@enfin/chat';
import Chat from '@enfin/chat';
function App() {
return (
<ChatProvider
config={{ apiKey: 'chat_xxx' }}
userId="user_123"
userName="Alice"
serverUrl="http://localhost:3002"
>
<Chat />
</ChatProvider>
);
}Pass serverUrl as your chat-server URL. It connects to ${serverUrl}/chat namespace and calls REST at ${serverUrl}/api/*.
Changelog
1.6.3 — Sidebar placeholder for newly registered peers
Bug fix: In a real consumer app where users register dynamically mid-session, the sidebar could crash when a brand-new user was added to a direct room before that user's record had been loaded into the chat context's users array. The room's other member wasn't in otherUsers, so chatRows pushed a kind: 'direct' row with peer === undefined, and the sidebar render threw Cannot read properties of undefined (reading 'userId').
The fix: Chat.tsx chatRows now synthesizes a placeholder ChatUser ({ userId, name: id }) from the member id when the peer isn't in otherUsers. The placeholder has both userId and name defined, so the row renders. The placeholder is replaced by the real user record on the next refreshUsers cycle.
Hardening: Confirmed zero localStorage reads in the package. The current user is identified ONLY via the userId prop passed to ChatProvider. The previous localStorage.getItem('userId') fallback in MessageBubble was already removed in 1.6.2; this release makes the no-localStorage contract explicit.
Migration from 1.6.2 → 1.6.3: drop-in. No API changes.
Bump policy: patch (bug fix; no API changes).
1.6.2 — Unread message counts + read receipts
The default <Chat /> component now shows a live unread badge (red pill) next to each user/group in the sidebar, updated in real time as messages arrive. Opening a chat clears the badge and marks the room as read for all members, who see a ✓✓ (full read) tick next to your messages.
What you get:
- Unread badge per user / per group — red pill in the top-right of each sidebar row. Updates live when you receive a message. Counter caps at
99+. - Real-time updates — receiving a
message:newincrements the badge in your open tab without a roundtrip. The server re-seeds the count on next reconnect viaGET /api/rooms?userId=&withUnread=true. - Mark-as-read on open — clicking a conversation sends
room:readover the socket; the server does a bulkupdateManyto add aReadReceiptto every message in the room up tonow. The result is broadcast to all other room members asroom:read-byso their clients can update the✓✓ticks on the messages they sent. - Read-receipt ticks:
✓(sent),✓✓gray (partial-read for groups),✓✓blue (fully read by everyone else). - Auto-clear sender's own messages — your own messages never count toward your unread total (you read them as you send them).
- Bulk update — opening a chat marks the whole room read in a single query, even if you have 100+ unread messages. No per-message updates.
Migration from 1.6.1 → 1.6.2: drop-in. The useChatContext() shape gains one new method: enterRoom(room: Room). Most consumers won't need it — <Chat /> already calls it internally when you click a sidebar row. Use it directly only if you're building a custom UI and you hit a stale-closure race where the new direct-room can't be opened on the first click.
Bump policy: minor (new feature; additive API only; no removals).
1.6.1 — Configurable top header
<Chat /> now accepts an optional header prop so the default topbar (brand text + current user avatar/name) can be customized or hidden entirely.
| Value | Result |
|-------|--------|
| header={true} (default) | Topbar renders with brand text "Chat App" on the left and the current user's avatar + name on the right. |
| header="My Company Chat" | Topbar renders with your brand text instead of "Chat App". |
| header={false} | Topbar is hidden completely. Use this when the consuming app already renders its own header/profile outside of the <Chat /> component. |
// Default — branded "Chat App" topbar
<Chat />
// Custom brand text
<Chat header="Acme Support" />
// Hide the topbar entirely (consumer app has its own header)
<Chat header={false} />Migration from 1.6.0 → 1.6.1: drop-in. The default behavior is unchanged.
1.4.4 — Group-call reliability fixes (cross-room join, audio deadlock, popup unification)
Bug fixes:
- Cross-room Join works again. Joining a group call from the sidebar's "other-room" popup now actually joins. Previously the click handler called the room-scoped
useGroupCall(otherRoomId)action, which was unmounted at the time and silently dropped the join. The cross-room popup'sonJoinnow emitsgroup-call:joinover the raw socket and switchescurrentRoomin the same tick. Works whether the popup is showing because the user has another room open or no room open at all. - No more re-ringing last-departed user. When the only participant left a multi-person group call, the hook used to seed
hasLeftCallRefwith the participant's id, which then blocked a brand-new outgoing call from ever inviting that user again.hasLeftCallRefnow resets tonullon hook unmount, so re-initiating a call in the same room re-rings everyone (including people who left the previous call). - Cross-room popup no longer sticks after End. Clicking End on a cross-room popup used to keep the entry in
groupCallsByRoombecausegroup-call:endedonly fires after the last peer leaves, but the room-scopeduseGroupCallcould already have cleaned up. NewmarkGroupCallLeft(roomId)callback onChatContextrecords the room id in agroupCallsLeftByUserRefSet; subsequentgroup-call:statebroadcasts for that room are ignored, andgroup-call:endedclears the entry from the Set. The popup disappears in the same tick the user clicks End. - Redundant in-room ringing banner removed. The dark
Group call / N membersbanner used to render on top of the unified in-room popup when the call was active but the local user had not yet joined. The banner's render condition now also gates ongroupCall.isInCall, so non-joiners in the calling room see only the unified Decline/Join popup — no double overlay. - Two-user audio now streams as soon as both join. The mesh used to deadlock for the first two joiners when the caller's initial offer was emitted before the joiner's hook mounted (the offer dropped on the floor) and the joiner's lex-initiate back to the caller was killed by the caller's glare guard. New
refreshPeermethod onGroupWebRTCManagercloses and recreates the peer'sRTCPeerConnectionso the caller can re-offer into a clean PC; the joiner'sonStatebranch pre-creates peer entries without sending any offers, waiting for the caller's refreshed offers to drive negotiation. Two-person calls now stream audio immediately; same path keeps N-person calls robust as participants come and go.
Design:
- The incoming-call popup and the cross-room active-call modal now share one wide horizontal bar style (rounded only on the bottom corners, full-width up to 720px, anchored to the top of the viewport), per the wireframe. The card sits above all chat content with
pointer-events: auto, leaves the room below clickable, and never blocks the message input.
Migration from 1.4.3 → 1.4.4: drop-in. No ChatConfig or ChatProvider prop changes. Consumers who don't import the context-level markGroupCallLeft (most users) need no changes.
1.4.3 — Mute toggle fix
useGroupCall's effect that was supposed to sync manager.isMuted to the local mic track dropped the track's enabled flag before the call connected, which muted the speaker for everyone once a track was attached. Removed the three <audio>.muted = isMuted effect blocks; mute now goes exclusively through manager.setMuted → RTCRtpSender.replaceTrack(null). Cross-browser behavior matches the documented mute contract.
Migration from 1.4.2 → 1.4.3: drop-in.
1.4.2 — Unified group-call popup, public component exports
The unified group-call popup now renders the same Decline/Join UI whether the user is inside the calling room or has another room (or no room) open. CreateGroupModal, GroupInfoPanel, StartGroupCallModal, and SidebarFilterPills (with their *Props types and FilterMode) are now re-exported from @enfin/chat's root index.ts.
Migration from 1.4.1 → 1.4.2: drop-in.
1.4.1 — Group chat & group call hooks
useGroup (create / add / remove / rename / leave) and useGroupCall (mesh group-call lifecycle) are now documented hook surfaces for consumers building fully-custom UIs. <ChatProvider> accepts optional maxGroupMembers and maxCallParticipants props to mirror the server-side CHAT_MAX_GROUP_MEMBERS / CHAT_MAX_CALL_PARTICIPANTS caps.
Migration from 1.4.0 → 1.4.1: drop-in.
1.4.0 — Group chat UI
WhatsApp-style group header → side panel for member management. Creator can rename or remove members; any member can add (capped at MAX_GROUP_MEMBERS = 256 by default). Mongoose Room.members is auto-tracked.
Migration from 1.3.x → 1.4.0: drop-in.
ChatProvider Props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| config | ChatConfig | Yes | { apiKey: string } |
| userId | string | Yes | Unique user ID |
| userName | string | Yes | Display name |
| serverUrl | string | Yes | Your chat-server URL |
| maxGroupMembers | number | No | Override the group-room member cap on the FE side. Defaults to 256 (matches the server default). Pass the same value you set CHAT_MAX_GROUP_MEMBERS to on the server. |
| maxCallParticipants | number | No | Override the group-call participant cap on the FE side. Defaults to 20 (matches the server). Hard cap. |
ChatConfig
interface ChatConfig {
apiKey: string;
validationUrl?: string; // optional custom validation endpoint
ringtoneUrl?: string; // optional override for the incoming-call ringtone
}ringtoneUrl — custom incoming-call ringtone
By default the SDK plays a bundled i_phone_message.mp3 whenever an audio call comes in. Pass ringtoneUrl to override it with your own file. Works with absolute URLs or any path the consumer's bundler/dev server can resolve.
<ChatProvider
config={{
apiKey: 'chat_xxx',
ringtoneUrl: '/sounds/my-ring.mp3', // served by your app
}}
userId="user_123"
userName="Alice"
serverUrl="http://localhost:3002"
>- Anything that HTML5
<audio>accepts works:/sounds/ring.mp3,https://cdn.example.com/ring.ogg, or a Vite/Webpackimportresolved URL. - If the override URL fails to load, the browser console logs
[RINGTONE] play() rejected: ...and the call UI keeps working. - The default mp3 is bundled at
dist/public/music/i_phone_message.mp3and shipped with the package — no extra setup needed.
Default Chat Component
The <Chat /> component provides full UI:
- User registration/login
- Room list (direct and group)
- Real-time messaging
- File uploads with drag-and-drop
- Typing indicators
- Online presence (green dot)
- 1:1 audio calls (WebRTC)
- Group chats with member management
- Group audio calls (mesh WebRTC, up to 20 participants)
Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| theme | 'light' \| 'dark' | 'light' | Visual theme. |
| header | boolean \| string | true | Top app bar (brand text on the left, current user avatar + name on the right). Pass false to hide it when the consuming app has its own header. Pass a string to set a custom brand text instead of "Chat App". |
// Default — topbar with "Chat App" brand
<Chat />
// Custom brand text
<Chat header="Acme Support" />
// No topbar (consumer app has its own header)
<Chat header={false} />Customize Appearance
Styles are in styles.css. Override CSS variables:
:root {
--chat-primary: #007bff;
--chat-bg: #ffffff;
--chat-text: #333333;
--chat-border: #e0e0e0;
--chat-radius: 8px;
--chat-font: system-ui, sans-serif;
}Build Custom UI
Use hooks instead of <Chat />:
import { useChatContext, useRooms, useMessages, useCall, useGroup, useGroupCall, useSocket } from '@enfin/chat';
import { ChatProvider } from '@enfin/chat';
function CustomChat() {
const { userId, messages, rooms, currentRoom, sendMessage, selectRoom } = useChatContext();
const { activeCall, startCall, endCall, acceptCall } = useCall();
const { createRoom, addRoomMember, removeRoomMember } = useGroup();
const { startGroupCall, leaveGroupCall, toggleMute } = useGroupCall(currentRoom?._id);
// Send message
await sendMessage(currentRoom._id, 'Hello!');
// Start 1:1 call
await startCall(currentRoom._id, 'participant_456');
// Create a group
await createRoom('Engineering', 'group', ['u1', 'u2', 'u3']);
// Start a group audio call (server fans out `group-call:incoming` to all members)
await startGroupCall(['u1', 'u2', 'u3']);
}useChatContext
Returns everything:
interface ChatContextValue {
userId: string;
userName: string;
messages: Message[];
rooms: Room[];
users: ChatUser[];
currentRoom?: Room;
activeCall?: AudioCall;
typingUsers: PresenceStatus[];
connected: boolean;
loading: boolean;
error?: string;
sendMessage: (roomId, content, file?) => Promise<void>;
selectRoom: (roomId) => void;
createRoom: (name, type, members) => Promise<Room>;
startCall: (roomId, participantId) => Promise<void>;
endCall: () => Promise<void>;
acceptCall: () => Promise<void>;
mute: () => void;
unmute: () => void;
isMuted: boolean;
socket?: Socket;
refreshUsers: () => Promise<void>;
}useRooms
const { rooms, loading, error, createRoom, openDirectRoom } = useRooms();useGroup
High-level group-room management. Wraps the room mutations with the default cap (maxGroupMembers, default 256).
const {
createRoom, // (name, 'group', members: string[]) => Promise<Room>
addRoomMember, // (roomId, userId) => Promise<void>
addRoomMembers, // (roomId, userIds: string[]) => Promise<void>
removeRoomMember, // (roomId, userId) => Promise<void>
renameRoom, // (roomId, name) => Promise<void>
leaveRoom, // (roomId) => Promise<void>
maxGroupMembers, // number — local cap (matches server env)
} = useGroup();useGroupCall
Mesh WebRTC group-call hook. Pass the roomId of the group you want to call from. The hook handles mic acquisition, signaling buffering (so offers that arrive before the user clicks Join are not lost), glare-free offer initiation via lexicographic userId ordering, and reconnect on socket drop.
import { useGroupCall, UseGroupCallResult } from '@enfin/chat';
const {
startGroupCall, // (participantIds: string[]) => Promise<{ ok, call?, error? }>
joinGroupCall, // (callId: string) => Promise<{ ok, call?, error? }>
leaveGroupCall, // () => Promise<void>
cancelGroupCall, // () => Promise<void> — caller-only; ends before anyone joined
toggleMute, // () => void
isMuted, // boolean
isInCall, // boolean — true once user is in `participantsInCall`
isRinging, // boolean — true while an incoming call is ringing for this user
activeCall, // AudioCall | undefined — read `kind: 'direct' | 'group'` to branch
participants, // string[] — userIds currently in the call
remoteStreams, // Map<userId, MediaStream> — one per peer; render <audio srcObject={stream}/>
callError, // string | undefined
callState, // 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'closed' | 'failed'
notification, // { kind: 'error' | 'info' | 'success', text: string } | undefined
clearNotification, // () => void
} = useGroupCall(roomId: string | undefined);UseGroupCallResult is exported alongside the hook for typing your own wrapper components.
Full custom-UI example
import { ChatProvider, useGroup, useGroupCall } from '@enfin/chat';
function GroupCallPanel({ roomId, memberIds }) {
const { createRoom } = useGroup();
const {
activeCall, isInCall, isRinging, participants,
remoteStreams, isMuted,
startGroupCall, joinGroupCall, leaveGroupCall, toggleMute,
} = useGroupCall(roomId);
return (
<div>
{!activeCall && (
<button onClick={() => startGroupCall(memberIds)}>Start group call</button>
)}
{isRinging && (
<>
<button onClick={() => activeCall && joinGroupCall(activeCall._id)}>Join</button>
<button onClick={leaveGroupCall}>Decline</button>
</>
)}
{isInCall && (
<>
<ul>{participants.map((u) => (
<li key={u}>
{u}
<audio autoPlay srcObject={remoteStreams.get(u)} />
</li>
))}</ul>
<button onClick={toggleMute}>{isMuted ? 'Unmute' : 'Mute'}</button>
<button onClick={leaveGroupCall}>End</button>
</>
)}
</div>
);
}The cap is maxCallParticipants (default 20, hard-capped at 20) and is enforced server-side. Past that, use an SFU.
useUsers
Manages users in the active tenant. Wraps the REST endpoints and the user:updated / user:removed socket events so every connected client stays in sync.
import { useUsers } from '@enfin/chat';
const {
users, // ChatUser[] — current snapshot
getUser, // (userId) => ChatUser | undefined
refreshUsers, // () => Promise<void>
refetchUser, // (userId) => Promise<ChatUser | undefined>
updateUser, // (userId, patch) => Promise<ChatUser>
deleteUser, // (userId, { cascade?: boolean }) => Promise<{ ok, userId, cascaded }>
} = useUsers();
// Update a user's profile fields
await updateUser('user_abc', {
statusMessage: 'In a meeting',
avatar: 'https://cdn.example.com/a.png',
});
// Delete a user. Pass `{ cascade: true }` to also drop rooms they own
// (and their messages) and remove them from any rooms they're a member of.
await deleteUser('user_abc', { cascade: true });Updatable profile fields: name, avatar, email, phoneNumber, countryCode, statusMessage, locale, timezone, role. All optional.
useMessages
const { messages, loading, sendMessage, markRead } = useMessages(roomId);useCall
const { activeCall, startCall, endCall, acceptCall, mute, unmute, isMuted } = useCall();useSocket
Raw Socket.IO access:
const socket = useSocket();
// socket.emit('message', { roomId, content });
// socket.on('message', (msg) => { ... });Reusable UI Pieces (Custom Layouts)
When you build your own layout but want to reuse the group's modals / filter pills instead of writing them from scratch:
import {
CreateGroupModal, // type CreateGroupModalProps
GroupInfoPanel, // type GroupInfoPanelProps
StartGroupCallModal, // type StartGroupCallModalProps
SidebarFilterPills, // type SidebarFilterPillsProps, FilterMode
} from '@enfin/chat';Each of these is the same component the default <Chat /> renders internally — they pair with useGroup() / useGroupCall() / useChatContext() so they share state with the SDK.
Types
All types are exported:
import {
// Shared transport types
Message, Room, AudioCall, PresenceStatus, ChatConfig,
// Group-call signaling
GroupCallSignalPayload, GroupCallStatePayload,
// Hook result types
UseGroupCallResult,
// UI prop types
ChatProps, CreateGroupModalProps, GroupInfoPanelProps,
StartGroupCallModalProps, SidebarFilterPillsProps, FilterMode,
} from '@enfin/chat';Environment Variables
Vite
VITE_CHAT_API_KEY=chat_xxx
VITE_SERVER_URL=http://localhost:3002CRA
REACT_APP_CHAT_API_KEY=chat_xxx
REACT_APP_SERVER_URL=http://localhost:3002Next.js
In .env.local:
NEXT_PUBLIC_CHAT_API_KEY=chat_xxx
NEXT_PUBLIC_SERVER_URL=http://localhost:3002Then access via process.env.NEXT_PUBLIC_*.
Server Requirement
Your backend must run @enfin/chat-server. The frontend SDK communicates with it via:
- Socket.IO:
${serverUrl}/chat - REST:
${serverUrl}/api/*
For backend server, see @enfin/chat-server.
