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

@scalemule/chat

v0.0.71

Published

ScaleMule standalone chat SDK — real-time messaging, presence, typing indicators

Downloads

914

Readme

@scalemule/chat

Real-time chat SDK for the ScaleMule platform — messaging, presence, typing indicators, named channels, search, support inbox, and pre-built React components.

npm install @scalemule/chat

Features

  • Real-time messaging over WebSocket with HTTP polling fallback
  • Presence & typing indicators with multi-tab safety
  • Named channels (Slack-style, public or private)
  • Full-text message search with highlighted excerpts
  • Message editing including attachment add/remove
  • Reactions with toggle semantics
  • File attachments (images, video, audio, files) via presigned upload
  • Support inbox — rep management, claim/resolve workflow, widget config
  • Pre-built React components and hooks
  • Framework-agnostic core (also ships a Web Component and iframe embed)

Quick start — React

import { ChatProvider, useChat } from '@scalemule/chat/react'

function App() {
  return (
    <ChatProvider
      config={{
        apiKey: 'pk_...',
        apiBaseUrl: 'https://api.scalemule.com',
        wsUrl: 'https://api.scalemule.com',
        userId: 'user-uuid',
      }}
    >
      <Conversation conversationId="conv-uuid" />
    </ChatProvider>
  )
}

function Conversation({ conversationId }: { conversationId: string }) {
  const { messages, sendMessage } = useChat(conversationId)
  return (
    <div>
      {messages.map((m) => (
        <div key={m.id}>{m.content}</div>
      ))}
      <button onClick={() => sendMessage('Hello!')}>Send</button>
    </div>
  )
}

For a complete chat UI out of the box, use <ChatThread>:

import { ChatThread } from '@scalemule/chat/react'

<ChatThread conversationId="conv-uuid" currentUserId="user-uuid" />

Date separators

By default, the message list renders separators as Today / Yesterday / weekday name (last 6 days) / Apr 4 / Apr 4, 2025.

Three knobs are available on both <ChatThread> and <ChatMessageList>:

| Prop | Purpose | | --- | --- | | formatDateLabel(iso) | Replace the default formatter entirely. | | dateLabelLocale | BCP-47 locale (e.g. 'en-GB', 'de-DE'). | | dateLabelTimeZone | IANA zone (e.g. 'America/New_York'). |

SSR hosts should pass dateLabelTimeZone (or formatDateLabel) so the server and client agree on the day boundary — otherwise "Today" vs "Yesterday" can flip during hydration around midnight.

Message grouping

Consecutive messages from the same sender within 5 minutes are grouped: the avatar and sender header are suppressed on follow-up messages, leaving a tighter visual cluster. System messages never group; date-separator and unread-divider boundaries always break grouping.

| Prop | Purpose | | --- | --- | | groupingWindowMs | Window in ms (default 300_000). Pass 0 to disable. |

The grouped wrapper carries the sm-message-grouped class — override in host CSS for further customization (e.g. hover-only timestamps).

Custom renderMessage consumers receive isGrouped in context and should honor it to preserve list polish.

Channel invitations

import {
  ChannelInvitationsModal,
  useChannelInvitations,
} from '@scalemule/chat/react'

// Badge in the header
const { unseenCount } = useChannelInvitations()
<button onClick={() => setOpen(true)}>
  Invitations {unseenCount > 0 ? `(${unseenCount})` : ''}
</button>

// Modal
<ChannelInvitationsModal
  open={open}
  onClose={() => setOpen(false)}
  onAccepted={(inv) => router.push(`/c/${inv.channel_id}`)}
/>

The hook seeds from listChannelInvitations() and reacts to channel:invitation:received / channel:invitation:resolved realtime events. Accept / reject are optimistic; rows restore on error. Unseen count persists across reloads via localStorage.

ChatClient exposes listChannelInvitations, inviteToChannel, acceptChannelInvitation, rejectChannelInvitation for hosts that want to integrate without the modal.

Channel admin

import { ChannelEditModal, ChannelHeader } from '@scalemule/chat/react'

<ChannelHeader
  channelId={c.id}
  name={c.name}
  description={c.description}
  onEdit={canEdit ? () => setEditOpen(true) : undefined}
  onLeave={() => leave(c.id)}
/>

<ChannelEditModal
  open={editOpen}
  onClose={() => setEditOpen(false)}
  initial={{ name: c.name, description: c.description, visibility: c.visibility }}
  onSave={async (v) => updateChannel(c.id, v)}
  onArchive={() => archive(c.id)}
/>

ChannelHeader renders an (i) icon when description is set; hover/focus shows the full description in a popover. <ChannelEditModal> is the matching settings form. Permission gating is host-side — open the modal only for users who can edit.

Channel system messages

import { defaultFormatSystemMessage, ChatMessageItem } from '@scalemule/chat/react'

<ChatMessageItem
  message={m}
  systemMessageProfiles={profiles}
  formatSystemMessage={defaultFormatSystemMessage} // optional; this is the default
/>

The default formatter handles system.channel.{joined,left,invited,created,renamed,archived} and system.call.{started,ended}. Pass a custom formatSystemMessage(content, profiles) to override. parseSystemMessage is also exported for use outside the chat UI (activity logs, audit views).

New-conversation modal

import { NewConversationModal } from '@scalemule/chat/react'

<NewConversationModal
  open={open}
  onClose={() => setOpen(false)}
  searchUsers={(q) => api.searchUsers(q)}
  onCreate={async (ids) => {
    const conv = await api.createDM(ids)
    router.push(`/messages/${conv.id}`)
  }}
  currentUserId={currentUserId}
/>

Multi-select user picker with debounced search (250ms default), keyboard navigation, focus trap, and error surfacing. Router-agnostic — host provides searchUsers and onCreate.

Active call indicator

import { ActiveCallDot, ConversationList } from '@scalemule/chat/react'

<ConversationList
  renderActiveIndicator={(c) => (
    <ActiveCallDot active={activeCallIds.has(c.id)} />
  )}
/>

Host wires the source of truth (conference presence, WebRTC signaling, etc.). <ActiveCallDot> is a pulsing green dot with pure-CSS animation — pass active={false} or omit the renderer entirely to disable.

Tokens: --sm-active-call-color, --sm-active-call-pulse-opacity (set to 0 to disable the pulse while keeping the dot).

Mention count badges

import { useMentionCounts, ConversationList } from '@scalemule/chat/react'

// Automatic: ConversationList calls the hook internally using currentUserId.
<ConversationList currentUserId={currentUserId} />

// Manual: host-supplied store wins over the internal hook.
const counts = useMentionCounts(currentUserId)
<ConversationList currentUserId={currentUserId} mentionCounts={counts} />

The badge reads @N and is styled via .sm-mention-badge using --sm-mention-badge-bg / --sm-mention-badge-text. The displayed count sums the server-side hint on Conversation.mention_count with the live hook overlay. showMentionBadge={false} suppresses the badge entirely.

Increments derive client-side from the mention blot's data-sm-user-id attribute, so nothing server-side changes — the feature lights up automatically once the host renders mentions.

Sectioned conversation list

<ConversationList
  groupBy="type"
  sectionOrder={['channel', 'group', 'direct']}
  sectionLabels={{ channel: 'TOPICS', direct: 'PEOPLE' }}
/>

groupBy="type" partitions rows by conversation_type and renders a collapsible header for each section. Per-section collapse state persists to localStorage (sm-conv-list-section-collapsed-v1) and degrades silently when storage is unavailable.

sectionOrder doubles as an inclusion filter — types omitted from the list are hidden entirely. sectionLabels overrides the default English labels (CHANNELS, GROUPS, DIRECT MESSAGES, etc.).

CSS hooks: .sm-conv-section, .sm-conv-section-{type}, .sm-conv-section-header.

Conversation display names

ConversationList resolves human-readable names for every row type:

<ConversationList
  currentUserId={currentUserId}
  profiles={profilesByUserId}
  selfLabel="— Saved"           // optional; default "(you)"
  formatGroupName={(names) =>   // optional; default "Alice, Bob, and N others"
    `${names.length} people`
  }
/>
  • 1:1 DM with self → "<your name> (you)"
  • Named channels / groups → conversation.name
  • Unnamed groups → "Alice, Bob, and N others" (current user filtered out)
  • 1:1 DM → other participant's display name, falling back to counterparty_user_idconversation.name → short id

resolveConversationDisplayName, buildDefaultGroupName, and otherParticipantNames are exported from @scalemule/chat (SSR-safe, React-free) for use in previews / notifications / system-message templates.

Typing indicator

import { ChatThread } from '@scalemule/chat/react'

<ChatThread
  conversationId={id}
  typingIndicatorPosition="below-composer"   // default 'above-composer'
  typingIndicatorLocale="en-US"
  formatTyping={(names) =>
    names.length === 1 ? `${names[0]} writes…` : `${names.join(' & ')} write…`
  }
/>

Name list formatting uses Intl.ListFormat (conjunction / long) for locale-aware separators. Pass formatTyping for full-sentence i18n ("Alice et Bob sont en train d'écrire…"). typingIndicatorPosition="none" suppresses the built-in indicator so hosts can drop <TypingIndicator> anywhere.

Offline detection

import { OfflineBanner, useConnectionStatus, ChatThread } from '@scalemule/chat/react'

function ChatPanel({ conversationId }: { conversationId: string }) {
  const { isOnline } = useConnectionStatus()
  return (
    <div style={{ display: 'flex', flexDirection: 'column' }}>
      <OfflineBanner />
      <ChatThread
        conversationId={conversationId}
        disableWhenOffline
      />
      {!isOnline && <p>Composing is paused while reconnecting…</p>}
    </div>
  )
}

<OfflineBanner> hides when the WebSocket is connected and renders an amber banner otherwise. disableWhenOffline on <ChatThread> disables both the plain and rich composers while disconnected — combines with any existing disabled state, never overrides it.

Self-status (Active / Away)

import {
  AvatarStatusMenu,
  useMyStatus,
} from '@scalemule/chat/react'

function AvatarButton() {
  const { status } = useMyStatus()
  const [open, setOpen] = useState(false)
  return (
    <div style={{ position: 'relative' }}>
      <button onClick={() => setOpen((o) => !o)}>
        <img src={myAvatar} alt="" />
        {status === 'away' && <span>⏸</span>}
      </button>
      {open && (
        <div style={{ position: 'absolute', top: '100%', right: 0 }}>
          <AvatarStatusMenu onClose={() => setOpen(false)} />
        </div>
      )}
    </div>
  )
}

Setting status to 'away' broadcasts a presence_update to every conversation where the user has joined presence. Other users see the amber dot (via the existing <StatusDot> + useConversationPresenceStatus wiring from 0.0.56). The choice persists across reloads — scoped per-user via applicationId + userId — and is re-applied automatically on reconnect.

The WebSocket ping keepalive is never touched. "Away" is a presence annotation, not a connection state.

Presence status indicators

import {
  StatusDot,
  useConversationPresenceStatus,
} from '@scalemule/chat/react'

function AvatarWithStatus({
  conversationId,
  userId,
  src,
}: {
  conversationId: string
  userId: string
  src?: string
}) {
  const status = useConversationPresenceStatus(conversationId, userId)
  return (
    <div style={{ position: 'relative', display: 'inline-block' }}>
      <img src={src} alt="" width={32} height={32} style={{ borderRadius: '50%' }} />
      <div style={{ position: 'absolute', right: -2, bottom: -2 }}>
        <StatusDot status={status} />
      </div>
    </div>
  )
}

The dot is a pure visual — host supplies the resolved status. useConversationPresenceStatus is conversation-scoped; pass the conversation where both viewer and subject have joined presence. Outside a conversation, pass a resolved status directly (e.g. from your own store).

Theme via --sm-status-online-color, --sm-status-away-color, --sm-status-offline-color, --sm-status-dot-border.

Search → jump to message (end-to-end)

The search UX composes cleanly with the scroll-and-highlight polish shipped in 0.0.45. When the user clicks a result, the host navigates to the conversation and hands the message id to <ChatThread highlightMessageId>, which scrolls the list to center the message and paints the amber fade animation.

import { useConversations, ChatThread } from '@scalemule/chat/react'
import {
  useGlobalSearch,
  useSearchHistory,
  SearchHistoryDropdown,
  SearchResultsPanel,
} from '@scalemule/chat/search'

function SearchableInbox({ currentUserId }: { currentUserId: string }) {
  const { conversations } = useConversations()
  const [q, setQ] = useState('')
  const [historyOpen, setHistoryOpen] = useState(false)
  const [panelOpen, setPanelOpen] = useState(false)

  const history = useSearchHistory({
    storageKey: `sm-search-history-v1:${currentUserId}`,
  })
  const search = useGlobalSearch(q, { conversations })

  const [selectedConv, setSelectedConv] = useState<string | null>(null)
  const [highlightMessageId, setHighlightMessageId] = useState<string | undefined>()

  return (
    <>
      <div style={{ position: 'relative' }}>
        <input
          value={q}
          onChange={(e) => setQ(e.target.value)}
          onFocus={() => setHistoryOpen(true)}
          onKeyDown={(e) => {
            if (e.key === 'Enter') {
              history.push(q)
              setHistoryOpen(false)
              setPanelOpen(true)
            }
          }}
          placeholder="Search all conversations…"
        />
        {historyOpen && (
          <div style={{ position: 'absolute', top: '100%', left: 0, right: 0 }}>
            <SearchHistoryDropdown
              history={history.history}
              onSelect={(v) => {
                setQ(v)
                setHistoryOpen(false)
                setPanelOpen(true)
              }}
              onClose={() => setHistoryOpen(false)}
              onClear={history.clear}
            />
          </div>
        )}
      </div>

      <SearchResultsPanel
        open={panelOpen}
        onClose={() => setPanelOpen(false)}
        results={search.results}
        isLoading={search.isLoading}
        progress={search.progress}
        errors={search.errors}
        onSelect={(result) => {
          setSelectedConv(result.conversationId)
          setHighlightMessageId(result.message.id)
          setPanelOpen(false)
        }}
      />

      {selectedConv && (
        <ChatThread
          conversationId={selectedConv}
          currentUserId={currentUserId}
          highlightMessageId={highlightMessageId}
        />
      )}
    </>
  )
}

Navigation is host-controlled. A Next.js / React Router app would swap setSelectedConv for router.push(...) and read the highlight id off the URL query string. The SDK never calls into a router.

Cross-conversation search

import { useConversations } from '@scalemule/chat/react'
import { useGlobalSearch, SearchResultsPanel } from '@scalemule/chat/search'

const { conversations } = useConversations()
const [q, setQ] = useState('')
const [open, setOpen] = useState(false)

const { results, isLoading, progress, errors } = useGlobalSearch(q, {
  conversations,    // REQUIRED — hook does not fetch the list itself
})

<SearchResultsPanel
  open={open}
  onClose={() => setOpen(false)}
  results={results}
  isLoading={isLoading}
  progress={progress}
  errors={errors}
  profiles={profilesByUserId}
  onSelect={(result) => {
    // Router-agnostic — host navigates and hands the message id to
    // <ChatThread highlightMessageId> (shipped in 0.0.45) for the
    // scroll-and-highlight polish.
    router.push(
      `/messages/${result.conversationId}?highlight=${result.message.id}`,
    )
    setOpen(false)
  }}
/>

useGlobalSearch fans out searchMessages calls with a default concurrency of 6 and a 300ms debounce. Results are annotated with conversationId (and conversation if you pass the full rows) and sorted newest-first. Per-conversation errors are captured in errors[] without blocking the others. When a new query arrives before results return, the prior query's results are discarded.

<SearchResultsPanel> is a slide-out overlay with focus trap + focus-restore-on-close + keyboard navigation. Theme the width via --sm-search-panel-width.

Search UX (opt-in entry)

Search UX ships in a separate code-split entry so hosts that don't render search pay no bundle cost:

// Core chat — used everywhere
import { ChatThread, ConversationList } from '@scalemule/chat/react'

// Search UX — import only in views that need it
import {
  HighlightedExcerpt,
  SearchHistoryDropdown,
  useSearchHistory,
} from '@scalemule/chat/search'

Search history with a controlled input:

const { history, push, clear } = useSearchHistory({
  storageKey: `sm-search-history-v1:${userId}`,  // scope per user
})
const [q, setQ] = useState('')
const [open, setOpen] = useState(false)

<div style={{ position: 'relative' }}>
  <input
    value={q}
    onChange={(e) => setQ(e.target.value)}
    onFocus={() => setOpen(true)}
    onKeyDown={(e) => {
      if (e.key === 'Enter') {
        push(q)
        setOpen(false)
      }
    }}
  />
  {open && (
    <div style={{ position: 'absolute', top: '100%', left: 0, right: 0 }}>
      <SearchHistoryDropdown
        history={history}
        onSelect={(q) => { setQ(q); setOpen(false) }}
        onClose={() => setOpen(false)}
        onClear={clear}
      />
    </div>
  )}
</div>

Rendering highlighted excerpts:

result.highlights.map((html, i) => (
  <HighlightedExcerpt key={i} html={html} />
))

Theme via --sm-search-highlight-bg / --sm-search-highlight-text.

<SearchBar> and useSearch (from @scalemule/chat/react) remain the single-conversation inline search surface — unchanged.

Scroll-to-message highlight

Pass highlightMessageId (on <ChatThread> or <ChatMessageList>) to scroll to a specific message and paint the search-hit treatment — a 2-second amber fade + left border. Typically wired to a search-result click.

The unread-divider emphasis (first unread message) renders independently as a subtle left-edge ring — no longer collapsed into the same chrome.

| CSS class | Applied when | | --- | --- | | .sm-message-highlighted | highlightMessageId === message.id | | .sm-message-unread-start | first unread message in the thread (and not also a search hit) |

Tokens for theming: --sm-highlight-bg, --sm-highlight-border, --sm-unread-divider-color.

Rich-link embeds (YouTube)

Opt-in via the @scalemule/chat/embeds entry — code-split so hosts that don't render embeds don't pay the bundle cost.

import { YouTubeEmbeds } from '@scalemule/chat/embeds'

<ChatThread
  conversationId={id}
  renderEmbeds={(msg) => <YouTubeEmbeds html={msg.content} />}
/>

Detection covers standard watch URLs, youtu.be short links, /embed/, and /shorts/. Titles are fetched best-effort via YouTube's oEmbed endpoint and cached to localStorage for 7 days. Storage access is guarded — SSR, private browsing, and quota-blocked browsers fall back to title-less embeds without crashing.

extractYouTubeIds is exported standalone for previews / notifications / search-index enrichment (SSR-safe).

URL auto-linkify

Plain-text messages auto-detect http/https/www. URLs and render them as <a class="sm-link-auto" target="_blank" rel="noopener noreferrer nofollow">. Trailing prose punctuation is trimmed; balanced parens are kept.

| Prop | Purpose | | --- | --- | | linkifyPlainText | Default true. Pass false to render raw text. |

The detection helper is also a public utility for previews, notifications, and search excerpts:

import { linkify, hasLinks } from '@scalemule/chat'

const segments = linkify('see https://example.com docs')
// → [{type:'text', value:'see '}, {type:'link', display:..., url:...}, ...]

It's SSR-safe (regex-only, no DOM).

Mention click handling

<span class="sm-mention" data-sm-user-id> and <span class="sm-channel-mention" data-sm-channel-id> chips inside HTML messages are clickable. Wire navigation via two callbacks:

<ChatThread
  conversationId={id}
  onMentionClick={(userId) => router.push(`/u/${userId}`)}
  onChannelMentionClick={(channelId) => router.push(`/c/${channelId}`)}
/>

When neither callback is provided, chips render as styled but inert text — useful for read-only views (e.g. archived threads).

Customize chip colors via --sm-mention-bg, --sm-mention-hover-bg, --sm-mention-text, --sm-channel-mention-bg, --sm-channel-mention-hover-bg, --sm-channel-mention-text.


Quick start — named channels

import { useChannels } from '@scalemule/chat/react'

function ChannelPicker() {
  const { channels, createChannel, joinChannel, leaveChannel } = useChannels()

  return (
    <div>
      <button onClick={() => createChannel({ name: 'general', visibility: 'public' })}>
        Create #general
      </button>
      {channels.map((ch) => (
        <div key={ch.id}>
          # {ch.name} — {ch.member_count} members
          {ch.is_member ? (
            <button onClick={() => leaveChannel(ch.id)}>Leave</button>
          ) : (
            <button onClick={() => joinChannel(ch.id)}>Join</button>
          )}
        </div>
      ))}
    </div>
  )
}

Or drop in the pre-built <ChannelList> and <ChannelBrowser> components.


Quick start — message search

import { SearchBar } from '@scalemule/chat/react'

<SearchBar conversationId="conv-uuid" placeholder="Search this conversation..." />

The component renders a search input and results dropdown inline. For custom UI, use the useSearch(conversationId) hook directly.


Quick start — support rep (RepClient)

import { RepClient } from '@scalemule/chat'

const rep = new RepClient({
  apiBaseUrl: 'https://api.scalemule.com',
  apiKey: 'pk_...',
  userId: 'rep-user-uuid',
  getToken: async () => getAccessToken(),
})

// Register as a support rep
await rep.register({ display_name: 'Alice' })

// Go online and start heartbeat
await rep.updateStatus('online')
rep.startHeartbeat()

// Fetch the inbox and claim the first waiting conversation
const inbox = await rep.getInbox({ status: 'waiting' })
if (inbox.data?.[0]) {
  await rep.claimConversation(inbox.data[0].id)
  // rep.chat is now routed to the support conversation; send messages with rep.chat.sendMessage
}

See docs/MIGRATION.md for a full walkthrough including the cookie-based auth recipe for admin dashboards.


Entry points

| Import | Contents | |--------|----------| | @scalemule/chat | ChatClient, RepClient, SupportClient, ChatController, all types | | @scalemule/chat/react | ChatProvider, hooks (useChat, useChannels, useSearch, useConversations, useUnreadCount, usePresence, useTyping, useConnection), and pre-built components | | @scalemule/chat/element | <scalemule-chat> Web Component | | @scalemule/chat/iframe | iframe embed bootstrap |


Documentation


License

MIT