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

@rizvanua/contact-chat

v0.1.3

Published

Telegram-backed contact-chat widget: framework-agnostic server core, headless React hooks, and unstyled UI.

Readme

@rizvanua/contact-chat

MIT license

Telegram-backed contact-chat widget: framework-agnostic server core, headless React hooks, and unstyled UI reference components.


Install

pnpm add @rizvanua/contact-chat
# or
npm install @rizvanua/contact-chat
# or
yarn add @rizvanua/contact-chat

Peer dependencies — install alongside the package:

# Required for the server store (Upstash Redis):
pnpm add @upstash/redis

# Required for React hooks:
pnpm add react react-dom jotai

# Optional for the /ui reference components (headless dialog, icons, class merging):
pnpm add @headlessui/react lucide-react tailwind-merge

Node 18+ is required (Web fetch, crypto.randomUUID).


Quick start (server)

import {
  createChatServer,
  upstashRedisStore,
  telegramTransport,
} from '@rizvanua/contact-chat/server';

const url = process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN;
if (!url || !token) throw new Error('Redis is not configured.');

export const chatServer = createChatServer({
  store: upstashRedisStore({ url, token }),
  // The transport owns the webhook secret — pass it here, not to createChatServer.
  transport: telegramTransport({
    botToken:      process.env.TELEGRAM_BOT_TOKEN ?? '',
    chatId:        process.env.TELEGRAM_CHAT_ID ?? '',
    webhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET ?? '',
  }),
  ipHashSalt: process.env.CHAT_IP_HASH_SALT ?? '',
});

For local dev and demos without Redis, use inMemoryStore() instead:

import { createChatServer, inMemoryStore, telegramTransport } from '@rizvanua/contact-chat/server';

export const chatServer = createChatServer({
  store: inMemoryStore(),
  // The transport owns the webhook secret — pass it here, not to createChatServer.
  transport: telegramTransport({ botToken: '...', chatId: '...', webhookSecret: '...' }),
  ipHashSalt: 'dev-salt',
});

Server

All handlers are Web-standard (request: Request) => Promise<Response>. Use toNextRoute to wire them into a Next.js App Router project.

Route files

app/api/chat/send/route.ts

import { toNextRoute } from '@rizvanua/contact-chat/server/nextjs';
import { chatServer } from '@/lib/chat-server';
export const POST = toNextRoute(chatServer.send);
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

app/api/chat/poll/route.ts

import { toNextRoute } from '@rizvanua/contact-chat/server/nextjs';
import { chatServer } from '@/lib/chat-server';
export const GET = toNextRoute(chatServer.poll);
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

app/api/chat/telegram-webhook/route.ts

import { toNextRoute } from '@rizvanua/contact-chat/server/nextjs';
import { chatServer } from '@/lib/chat-server';
export const POST = toNextRoute(chatServer.webhook);
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

See examples/nextjs-app/ for the complete reference wiring.

Env resolution

The consumer's app resolves credentials — the package never reads process.env directly.

The Vercel Upstash integration provisions KV_REST_API_URL / KV_REST_API_TOKEN. The standalone Upstash integration uses UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN. Support both with || (not ??), because vercel env pull can write blank entries:

const url = process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN;
if (!url || !token) throw new Error('Redis is not configured.');

Full set of env vars the consumer configures:

| Variable | Description | | --- | --- | | UPSTASH_REDIS_REST_URL / KV_REST_API_URL | Redis REST URL (dual Upstash pair) | | UPSTASH_REDIS_REST_TOKEN / KV_REST_API_TOKEN | Redis REST token (dual Upstash pair) | | TELEGRAM_BOT_TOKEN | Bot token from BotFather | | TELEGRAM_CHAT_ID | Telegram group/supergroup numeric ID | | TELEGRAM_WEBHOOK_SECRET | Secret for webhook verification | | CHAT_IP_HASH_SALT | Salt for hashing visitor IPs |

setWebhook

Register your webhook URL with Telegram once (or whenever your domain changes). See Telegram docs: setWebhook.

curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
  -H 'Content-Type: application/json' \
  -d "{\"url\":\"https://your.domain/api/chat/telegram-webhook\",\"secret_token\":\"$TELEGRAM_WEBHOOK_SECRET\",\"allowed_updates\":[\"message\"]}"

Telegram + Upstash setup


React (client)

Import from @rizvanua/contact-chat/react. Requires react, react-dom, and jotai as peers.

<ChatClientProvider>

Wrap your app (or the subtree containing the chat widget) with ChatClientProvider. All props are optional — defaults work out of the box.

// app/providers.tsx  (Next.js App Router example)
'use client';

import { ChatClientProvider } from '@rizvanua/contact-chat/react';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ChatClientProvider
      basePath="/api/chat"
      chime={process.env.NEXT_PUBLIC_CHAT_CHIME !== 'false'}
      onEvent={(e) => {
        if (e.type === 'chat.send.success') {
          // e.g. gtag('event', 'contact_chat_sent')
        }
      }}
    >
      {children}
    </ChatClientProvider>
  );
}

| Prop | Type | Default | Description | | --- | --- | --- | --- | | basePath | string | '/api/chat' | Base path for the three route files (/send, /poll, /telegram-webhook). | | sessionStorageKey | string | 'contact-chat-session' | localStorage key for the session id. | | nameStorageKey | string | 'contact-chat-name' | localStorage key for the visitor's name. | | chime | boolean | true | Play a two-note synthesized chime when an owner reply arrives. | | maxMessageLength | number | 1000 | Client-side max message length; should match the server's ChatLimits.maxMessageLength (defaults to DEFAULT_CHAT_LIMITS.maxMessageLength). | | onEvent | (e: ChatClientEvent) => void | — | Optional analytics callback fired on lifecycle events (chat.open, chat.close, chat.send.attempt, chat.send.success, chat.send.error). |

useChatActions()

Reads chat state and provides action callbacks. Safe to call from any number of components — it contains no polling effects.

'use client';

import { useChatActions } from '@rizvanua/contact-chat/react';

export function MyComposer() {
  const { open, setOpen, messages, unread, status, error, name, setName, send, canSend } =
    useChatActions();

  if (!open) {
    return (
      <button onClick={() => setOpen(true)}>
        Chat {unread > 0 && <span>{unread}</span>}
      </button>
    );
  }

  return (
    <div>
      {messages.map((msg) => (
        <p key={msg.id}>{msg.text}</p>
      ))}
      {status === 'error' && <p>{error}</p>}
      <button
        disabled={!canSend}
        onClick={() => send('Hello from my bespoke composer')}
      >
        Send
      </button>
      <button onClick={() => setOpen(false)}>Close</button>
    </div>
  );
}

Return shape:

| Field | Type | Description | | --- | --- | --- | | open | boolean | Whether the chat panel is open. | | setOpen | (v: boolean \| ((prev: boolean) => boolean)) => void | Opens or closes the panel. Fires chat.open / chat.close on the onEvent callback. | | messages | ChatMessage[] | All messages in the session, sorted by server timestamp. | | unread | number | Count of owner replies that arrived while the panel was closed. | | status | 'idle' \| 'sending' \| 'error' \| 'unavailable' | Send status. | | error | string \| null | Human-readable error message when status === 'error'. | | name | string \| null | The visitor's name once set. | | setName | (raw: string) => boolean | Sanitizes and stores the name; returns false when the name is unusable (empty after stripping). | | send | (text: string) => Promise<void> | Sends a message; appends it optimistically. | | canSend | boolean | false while a send is in-flight (status === 'sending'). |

useChatSync()

Owns the polling lifecycle: hydrates session/name from localStorage, runs adaptive polling (4 s while open, 15 s when idle, pauses for backgrounded tabs), and fires the chime on owner replies.

Mount this exactly once in the component tree. Two mounts run two racing polling loops on the same cursor. The simplest placement is inside <ChatLauncher> (which calls it internally), or inside a single <providers.tsx> component that is guaranteed to mount once.

// app/providers.tsx  — mount useChatSync once alongside the provider
'use client';

import { ChatClientProvider } from '@rizvanua/contact-chat/react';
import { useChatSync } from '@rizvanua/contact-chat/react';

function ChatSync() {
  useChatSync();
  return null;
}

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ChatClientProvider>
      <ChatSync />
      {children}
    </ChatClientProvider>
  );
}

If you use <ChatLauncher> from the /ui entry, skip the manual useChatSync mount — ChatLauncher already calls it.

Advanced exports

mergeMessages, the Jotai atoms (chatOpenAtom, chatMessagesAtom, etc.), and playChime are all exported from @rizvanua/contact-chat/react for advanced consumers who want to integrate the state into an existing Jotai store or trigger the chime from custom logic.


UI (unstyled reference components)

Import from @rizvanua/contact-chat/ui. Requires @headlessui/react, lucide-react, and tailwind-merge as optional peers.

pnpm add @headlessui/react lucide-react tailwind-merge

All components ship with sensible Tailwind defaults (dark theme, purple accent). Override any class with the classNames prop — classes are merged via tailwind-merge so specificity conflicts are resolved correctly.

<ChatLauncher>

A floating button that opens the chat panel. Internally calls useChatSync(), so mount it exactly once.

import { MessageCircle } from 'lucide-react';
import { ChatLauncher } from '@rizvanua/contact-chat/ui';

export default function Page() {
  return (
    <main>
      <h1>My page</h1>
      <ChatLauncher
        icon={<MessageCircle size={18} />}
        labels={{ button: 'Talk to me' }}
        classNames={{ button: 'bg-blue-600 hover:bg-blue-700' }}
        onEvent={(e) => console.log(e.type)}
      />
    </main>
  );
}

Props:

| Prop | Type | Default | Description | | --- | --- | --- | --- | | labels.button | string | 'Contact me' | Button text. | | labels.unreadAriaLabel | (n: number) => string | (n) => `${n} unread ${n === 1 ? 'reply' : 'replies'}` | ARIA label for the unread badge. | | classNames.button | string | — | Extra classes merged onto the button. | | classNames.unreadBadge | string | — | Extra classes merged onto the badge dot. | | icon | React.ReactNode | — | Icon rendered inside the button. Pass <MessageCircle /> or any SVG. | | onEvent | (e: { type: 'ui.launcher.open' }) => void | — | Fires when the launcher button is clicked. | | children | React.ReactNode | <ChatDialog /> | Custom dialog; pass null to skip the dialog mount. |

<ChatDialog>

The chat panel: header with name, message list, name gate, and composer. Uses @headlessui/react <Dialog> with transition.

import { ChatDialog } from '@rizvanua/contact-chat/ui';

// Used inside a ChatLauncher's children prop to provide a custom dialog:
<ChatLauncher>
  <ChatDialog
    labels={{
      title: 'Get in touch',
      greeting: (name) => `Hey ${name}! Drop me a line.`,
      unavailableFallback: <a href="mailto:[email protected]">Email me instead</a>,
    }}
    classNames={{ panel: 'max-w-sm' }}
  />
</ChatLauncher>

Key props:

| Prop | Description | | --- | --- | | labels.title | Header title. Default: 'Chat'. | | labels.titleWithName | Suffix appended when name is set. Default: (n) => ` · ${n}`. | | labels.greeting | Shown once name is accepted. Default: (n) => `Hi ${n} — type your message here… I usually reply within a few minutes.`. | | labels.composerPlaceholder | (nameOk: boolean) => string. Default: (nameOk) => nameOk ? 'Type a message…' : 'Enter your name first'. | | labels.messageAriaLabel | ARIA label on the textarea. Default: 'Message'. | | labels.sendAriaLabel | ARIA label on the send button. Default: 'Send message'. | | labels.closeAriaLabel | ARIA label on the close (X) button. Default: 'Close chat'. | | labels.unavailableFallback | React.ReactNode shown on 'unavailable' status. Default: a "Chat is unreachable right now" paragraph. | | classNames.* | Per-element class overrides: overlay, panel, header, title, closeButton, body, greeting, unavailable, errorText, composer, honeypot, textarea, sendButton. | | renderMessage | (message: ChatMessage) => React.ReactNode — replaces <MessageBubble>. | | nameGate | React.ReactNode — replaces <NameGate>. |

<NameGate>

An inline name-entry form. Rendered automatically by <ChatDialog> until the visitor enters a valid name. Pass nameGate={<NameGate labels={{ prompt: 'Who are you?' }} />} to <ChatDialog> to customise it.

| Prop | Description | | --- | --- | | labels.prompt | Default: "Before we start — what's your name?" | | labels.placeholder | Default: 'Your name' | | labels.submitButton | Default: 'Start chat' | | labels.inputAriaLabel | Default: 'Your name' | | labels.error | Default: 'Please enter your name.' | | classNames.* | Per-element class overrides (container, prompt, inputRow, input, submitButton, error). |

<MessageBubble>

Renders a single ChatMessage as a chat bubble (visitor right-aligned purple, owner left-aligned grey). Usually rendered internally by <ChatDialog>, but exported for consumers who pass a custom renderMessage.

import { MessageBubble } from '@rizvanua/contact-chat/ui';

renderMessage={(msg) => <MessageBubble message={msg} classNames={{ bubble: 'text-base' }} />}

Working styled reference

See examples/nextjs-app/app/page.tsx for a complete, styled implementation using all four components together.


Configuration

Set up Telegram

You need a bot and a group with Topics enabled. Each visitor conversation becomes its own topic thread, so replying in-thread can never mis-route.

  1. Create the bot. Message @BotFather, send /newbot, and follow the prompts. Copy the token it gives you — this is TELEGRAM_BOT_TOKEN.
  2. Create a group and enable Topics. Make a new private group, open Group Settings → Topics, and turn Topics on.
  3. Add the bot as an admin. Add your bot to the group and promote it to admin with the Manage Topics permission (it needs to create/rename topic threads).
  4. Get the group's chat id. The target group id is a negative integer. The simplest way: add @RawDataBot to the group temporarily and read message.chat.id from its dump (supergroups look like -1001234567890), then remove it. Set this as TELEGRAM_CHAT_ID.
  5. Pick a webhook secret. Generate a random string (e.g. openssl rand -hex 32) and set it as TELEGRAM_WEBHOOK_SECRET. Telegram sends it back in the X-Telegram-Bot-Api-Secret-Token header so the webhook can reject forged requests.
  6. Register the webhook against your production URL (one bot supports one webhook URL). Run this once after deploying:
curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \
  -d "url=https://your-domain.com/api/chat/telegram-webhook" \
  -d "secret_token=<TELEGRAM_WEBHOOK_SECRET>"

Verify it stuck with curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/getWebhookInfo".

Set up Redis (Upstash)

Storage is HTTP-based Upstash Redis (no serverless connection pooling issues, native per-key TTL). Transcripts carry a 7-day sliding TTL, so retention is automatic with no cleanup job.

  1. Create a database. Sign in at console.upstash.com, create a Redis database (pick a region close to your deployment). The free tier far exceeds this widget's traffic.
  2. Copy the REST credentials. From the database page, copy the REST URL and REST token — these are UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.
  3. Or use the Vercel integration. If you provision Upstash through the Vercel Marketplace, it writes KV_REST_API_URL / KV_REST_API_TOKEN instead. Your app should prefer the UPSTASH_* pair and fall back to the KV_REST_API_* pair with || (not ??), since vercel env pull can write blank entries.
  4. Set a hash salt. Generate a random CHAT_IP_HASH_SALT (e.g. openssl rand -hex 16). It salts the SHA-256 IP hashing used for rate limiting — raw IPs are never stored.

For local dev and demos you can skip Redis entirely and use inMemoryStore() (see the Quick start above).

Env vars

Env vars are the consumer's responsibility — the package never reads process.env directly. Configure these in your .env.local / Vercel environment settings:

UPSTASH_REDIS_REST_URL=...
UPSTASH_REDIS_REST_TOKEN=...
# or, if using the Vercel Upstash integration:
KV_REST_API_URL=...
KV_REST_API_TOKEN=...

TELEGRAM_BOT_TOKEN=...
TELEGRAM_CHAT_ID=...
TELEGRAM_WEBHOOK_SECRET=...
CHAT_IP_HASH_SALT=...

Feature flags / hooks

Feature flags and analytics hooks are handled via the package's own extension points:

  • Feature flag / gtag events — pass onEvent to <ChatClientProvider> or <ChatLauncher>.
  • Custom classNames — pass classNames to <ChatLauncher> / <ChatDialog> to apply your design system classes.
  • Unavailability fallback — pass labels.unavailableFallback to <ChatDialog> (e.g. an email link).

Development

See CONTRIBUTING.md for the build, test, and release workflow.


License

MIT — see LICENSE.