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

@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-client

Quick 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:new increments the badge in your open tab without a roundtrip. The server re-seeds the count on next reconnect via GET /api/rooms?userId=&withUnread=true.
  • Mark-as-read on open — clicking a conversation sends room:read over the socket; the server does a bulk updateMany to add a ReadReceipt to every message in the room up to now. The result is broadcast to all other room members as room:read-by so 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's onJoin now emits group-call:join over the raw socket and switches currentRoom in 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 hasLeftCallRef with the participant's id, which then blocked a brand-new outgoing call from ever inviting that user again. hasLeftCallRef now resets to null on 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 groupCallsByRoom because group-call:ended only fires after the last peer leaves, but the room-scoped useGroupCall could already have cleaned up. New markGroupCallLeft(roomId) callback on ChatContext records the room id in a groupCallsLeftByUserRef Set; subsequent group-call:state broadcasts for that room are ignored, and group-call:ended clears 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 members banner 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 on groupCall.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 refreshPeer method on GroupWebRTCManager closes and recreates the peer's RTCPeerConnection so the caller can re-offer into a clean PC; the joiner's onState branch 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.setMutedRTCRtpSender.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/Webpack import resolved 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.mp3 and 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:3002

CRA

REACT_APP_CHAT_API_KEY=chat_xxx
REACT_APP_SERVER_URL=http://localhost:3002

Next.js

In .env.local:

NEXT_PUBLIC_CHAT_API_KEY=chat_xxx
NEXT_PUBLIC_SERVER_URL=http://localhost:3002

Then 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.