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

@repzo/widget-react-native

v1.0.0

Published

Repzo chat widget for React Native — pure JavaScript, no native modules

Readme

@repzo/widget-react-native

The Repzo chat widget for React Native — an Intercom-style messenger that drops into any app. Pure JavaScript/TypeScript: no native modules of its own, no config plugin, no pod install. Works on iOS and Android, Expo and bare React Native, old and new architecture.

It talks to the same /api/widget/* endpoints as the web widget (Workstation → Settings → Inbox Channels → Website Chat), so a message sent from the app lands in the CRM Inbox as a web_widget conversation and agent replies stream back live over SSE.

Features

  • Home + conversations + threaded chat, translated (en/ar, RTL-aware)
  • Session resume, anonymous → identified migration, HMAC identity verification
  • Shared-device safe: logout revokes the token and wipes all visitor data; tokens can live in Keychain/Keystore
  • Live agent replies, typing indicators, unread badges (server-backed counts)
  • Catches up after a dropped connection or a trip to the background — no missed or duplicated replies
  • Agent UI blocks: product cards, slot pickers, lead forms, quotes, receipts
  • Push notifications via FCM (optional — needs @react-native-firebase/messaging in the host app)
  • Theme cached on-device, so relaunches paint your colors instantly
  • Events for building your own badge, in-app banner, and tray cleanup

Requirements

| Peer | Status | | --- | --- | | react, react-native | required | | @react-native-async-storage/async-storage | optional — default token persistence (or inject your own storage, see below) | | @react-native-firebase/app + @react-native-firebase/messaging | optional — only for push notifications (v22+ and v26+ modular API both supported) | | react-native-linear-gradient | optional — gradient home backgrounds | | react-native-safe-area-context | optional — safe-area insets from the host provider |

Realtime uses React Native’s built-in streaming XMLHttpRequest; there are no bundled runtime dependencies.

Installation

Not on npm yet — install from Git. The fixes below are on main; replace it with a release tag to pin a version after the next release:

# Yarn / Expo projects
yarn add @repzo/widget-react-native@git+https://github.com/Repzo/repzo-widget-react-native.git#main

# npm
npm install @repzo/widget-react-native@git+https://github.com/Repzo/repzo-widget-react-native.git#main

Expo: no config plugin and no npx expo prebuild needed — the SDK itself runs in Expo Go. (Push notifications are the one exception: they need a development build, see below.)

Metro setup

Wrap your app's final Metro config with withRepzoWidget. This is required when any optional integration is absent. Metro resolves static require() calls before JavaScript runs, so runtime try/catch alone cannot make an uninstalled integration optional.

// metro.config.js — Expo
const { getDefaultConfig } = require('expo/metro-config');
const { withRepzoWidget } = require('@repzo/widget-react-native/metro');

module.exports = withRepzoWidget(getDefaultConfig(__dirname));
// metro.config.js — bare React Native
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const { withRepzoWidget } = require('@repzo/widget-react-native/metro');

module.exports = withRepzoWidget(mergeConfig(getDefaultConfig(__dirname), {
  // Your existing config.
}));

Keep your existing config/plugins and apply this wrapper last. It preserves installed integrations and existing custom resolvers, substituting an empty module only for missing optional imports made by this widget. Missing host imports and broken installed packages still report their errors. Restart Metro with its cache reset after changing the config (npx expo start --clear or npx react-native start --reset-cache).

Quick start

// App.tsx — mount the provider once, anywhere near the root
import { RepzoWidgetProvider } from '@repzo/widget-react-native';

export default function App() {
  return (
    <RepzoWidgetProvider>
      <YourApp />
    </RepzoWidgetProvider>
  );
}
import RepzoWidget from '@repzo/widget-react-native';

// Once, at startup — talks to production Workstation by default
await RepzoWidget.boot({ channelAccountId: '<widget id>' });

// After your user logs in
await RepzoWidget.loginUserWithUserAttributes({
  userId: user.id,
  name: user.name,
  userHash: user.repzoUserHash, // server-generated — see Identity verification
});

// Open the messenger from your own button
await RepzoWidget.present();

// In your sign-out flow — await it (see "Signing out" below)
await RepzoWidget.logout();

channelAccountId is the last path segment of /settings/inbox-channels/<id>/widget in Workstation. For a dev/staging CRM pass baseUrl too — Android emulators reach a CRM on your machine via http://10.0.2.2:3000, never localhost. baseUrl must be https://; boot() throws for plain http:// unless the host is localhost, 127.0.0.1, 10.0.2.2 or *.local, or the app is a __DEV__ build.

Signing out (host duties)

Call await RepzoWidget.logout() whenever your user signs out, before the next person can use the device. It:

  1. revokes the visitor token on the server (POST /api/widget/logout) and removes this device's push registration — best-effort, each capped at 5s, never re-initing a session;
  2. wipes every user-scoped key (token, user key, unread counts) and rotates the anonymous device id, so the next visitor gets a brand-new identity;
  3. closes the SSE stream, drops the app-state listeners and resets the UI.

Only the cached channel theme survives. Nothing reconnects until your next boot() / loginUser…() call.

You don't need logout() between two logins — loginUserWithUserAttributes() with a different user signs the previous one out the same way first, and never sends their token. It is still required on sign-out: without it, the stored session (and its conversations) stays on the device until the next login replaces it.

Identity calls (boot, loginUserWithUserAttributes, loginUnidentifiedUser, logout) are queued, and a later call supersedes any still in flight — so firing them from auth-state listeners without awaiting is safe, and the last call wins.

Identity verification

Anyone who knows a userId could otherwise impersonate that user. Your server signs the id with the channel's HMAC secret (Workstation → widget → Security tab) and ships the signature to the app:

userHash = HMAC_SHA256(hmacSecret, email ?? userId)   // hex

Pass it as userHash in loginUserWithUserAttributes. The secret must never ship inside the app bundle. Optional until the channel enforces it ("mandatory" toggle), then unverified logins are rejected.

Custom storage

The SDK persists the device id, unread counts and the cached theme through async-storage when installed — or through anything you give it (MMKV shown). Call this before the first boot():

RepzoWidget.setStorageAdapter({
  getItem: (key) => storage.getString(key) ?? null,
  setItem: (key, value) => storage.set(key, value),
  removeItem: (key) => storage.delete(key),
});

Without async-storage and without an adapter it falls back to memory — the session then only lasts until the app is killed.

Secure token storage (recommended)

AsyncStorage is plaintext on disk. The visitor token (a bearer credential, valid 14 days) and the user key can go to the Keychain / Android Keystore instead — same adapter shape, set before the first boot(). Without a secure adapter they fall back to the regular store above; with one, a token already in the regular store is moved over on first read. If a secure write fails the token is kept in memory only (never written as plaintext).

expo-secure-store (npx expo install expo-secure-store):

import * as SecureStore from 'expo-secure-store';

const secureOptions = { keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK };

RepzoWidget.setSecureStorageAdapter({
  getItem: (key) => SecureStore.getItemAsync(key, secureOptions),
  setItem: (key, value) => SecureStore.setItemAsync(key, value, secureOptions),
  removeItem: (key) => SecureStore.deleteItemAsync(key, secureOptions),
});

react-native-keychain (one keychain entry per key, via service):

import * as Keychain from 'react-native-keychain';

RepzoWidget.setSecureStorageAdapter({
  getItem: async (key) => {
    const entry = await Keychain.getGenericPassword({ service: key });
    return entry ? entry.password : null;
  },
  setItem: async (key, value) => {
    await Keychain.setGenericPassword('repzo-widget', value, {
      service: key,
      accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK,
    });
  },
  removeItem: async (key) => {
    await Keychain.resetGenericPassword({ service: key });
  },
});

Note that iOS keeps Keychain items across app reinstalls; a stale token there simply expires or gets replaced on the next init.

Links in messages

Agent and AI replies, campaign CTAs and attachments are remote content, so the SDK only opens https:, http:, mailto: and tel: links. Everything else (your own myapp:// deep links, intent:, sms:, file:, javascript:…) is blocked. To route links yourself, pass onOpenUrl — it runs first for every link; return true when you handled it:

await RepzoWidget.boot({
  channelAccountId: '<widget id>',
  onOpenUrl: (url) => {
    if (url.startsWith('myapp://orders/')) {
      navigation.navigate('Order', { id: url.split('/').pop() });
      return true;
    }
    return false; // fall back to the default allow-list
  },
});

Only accept deep links you would be comfortable with anyone sending your user in a chat message.

Errors

boot() / login calls reject with a RepzoWidgetError (status, code). Boot failures and the terminal codes below also land in useWidgetState().error / errorCode, and the messenger shows an error screen instead of a chat that only looks usable (even when the cached theme is already painted). Network/HTTP failures get a Retry button, and the SDK also retries by itself when the app returns to the foreground; terminal codes get a plain message and no Retry.

| code | Meaning | | --- | --- | | network | No connection / request failed before a response. | | http | Any other non-2xx response. | | messenger_api_disabled | The channel's Messenger API is switched off in Workstation. Terminal: realtime stops and the SDK doesn't retry on its own. | | identity_verification_failed | The channel enforces identity verification and userHash was missing or wrong. Terminal, as above. |

Expired or revoked tokens (401) are handled silently: the SDK re-inits once (shared by all concurrent requests) and retries.

Push notifications (optional)

Agent replies are pushed only when the user has no live connection (app backgrounded or killed — a visitor watching the chat gets the message over SSE instead). Several replies collapse into one notification per conversation, and tapping it deep-links straight into that conversation from both background and killed states. Delivery is FCM-only: Firebase relays iOS pushes through APNs itself.

1. Firebase in the app

The push feature is the one thing that needs a native module — Firebase Messaging, owned by the host app (Expo Go can't run it; use a development build):

# Expo
npx expo install @react-native-firebase/app @react-native-firebase/messaging expo-build-properties
// app.json / app.config.js
{
  "expo": {
    "plugins": [
      "@react-native-firebase/app",
      "@react-native-firebase/messaging",
      ["expo-build-properties", { "ios": { "useFrameworks": "static" } }]
    ],
    "ios": { "googleServicesFile": "./GoogleService-Info.plist" },
    "android": { "googleServicesFile": "./google-services.json" }
  }
}

Then npx expo prebuild and rebuild. Bare React Native: add the two config files from your Firebase project (GoogleService-Info.plist into the Xcode target, google-services.json into android/app/ with the com.google.gms.google-services Gradle plugin) and pod install.

2. Android notification channel

Android only pops a heads-up banner for high-importance channels. Create one named repzo_widget_messages at startup (Workstation targets it by id; without it, pushes land silently in the tray):

import * as Notifications from 'expo-notifications';

Notifications.setNotificationChannelAsync('repzo_widget_messages', {
  name: 'Chat messages',
  importance: Notifications.AndroidImportance.MAX,
  vibrationPattern: [0, 250, 250, 250],
  enableVibrate: true,
});

3. Enable at boot

await RepzoWidget.boot({
  channelAccountId: '<widget id>',
  push: {
    enabled: true,
    requestPermission: true,   // false if your app runs its own prompt
    bundleId: 'com.your.app',  // must match the app entry in Workstation
  },
});

The SDK registers the device token after every successful boot/login, moves it between identities on login switches, refreshes it when Firebase rotates it, removes it on logout(), and handles notification taps. Nothing else to wire.

4. Workstation side

Channel → Install tab → mobile → Set up push notifications:

  1. Delivery provider → FCM.
  2. Apps → "Configure an app" → your app's name, bundle id, and the service-account key JSON of your own Firebase project (Project settings → Service accounts → Generate new private key). One entry per app embedding this SDK — tokens are matched to keys by bundle id.
  3. iOS only, one time: upload your APNs .p8 in the Firebase console (Project settings → Cloud Messaging). Repzo's servers never see it.

The two Google config files in your app and the service-account key in Workstation must come from the same Firebase project, or sends fail with SENDER_ID_MISMATCH.

Keep the tray clean

The OS never removes a notification just because the user read the conversation in-app. Dismiss it yourself:

import * as Notifications from 'expo-notifications';
import RepzoWidget, { RepzoWidgetEvents } from '@repzo/widget-react-native';

RepzoWidget.addEventListener(RepzoWidgetEvents.ConversationDidRead, async ({ conversationId }) => {
  const presented = await Notifications.getPresentedNotificationsAsync();
  for (const n of presented) {
    const data = n.request.content.data ?? {};
    if (data.document_type === 'widget_message' && data.document_id === conversationId) {
      await Notifications.dismissNotificationAsync(n.request.identifier);
    }
  }
});

No Firebase module?

Bring your own token pipeline and hand the token over:

await RepzoWidget.setPushToken({ token, platform: 'android', bundleId: 'com.your.app' });

Events

const sub = RepzoWidget.addEventListener(RepzoWidgetEvents.UnreadCountDidChange, ({ count }) =>
  setBadge(count)
);
// later
sub.remove();

| Event | Payload | Use it for | | --- | --- | --- | | UnreadCountDidChange | { count } | Badge on your own chat button. count is the number of unread team replies across all conversations; it drops only when the visitor opens a thread (opening the messenger alone doesn't clear it). Counts are server-backed, so replies that arrived while the app was killed are included after a cold start. | | MessageDidArrive | { conversationId, message, conversation } | Your own in-app banner while the app is foregrounded (no push is sent then). Fires only for team replies that count as unread — never the visitor's echo, never system rows like "agent joined", never the thread already open. | | ConversationDidRead | { conversationId } | Dismissing that conversation's push notification (example above). | | WindowDidShow / WindowDidHide | — | Pause your own UI (tours, toasts) while the messenger is open. |

API

| Method | Notes | | --- | --- | | boot({ channelAccountId, baseUrl?, user?, push?, onOpenUrl? }) | Mints/resumes the session and connects SSE. Idempotent; safe to call repeatedly. | | loginUserWithUserAttributes({ userId?, email?, name?, phone?, userHash? }) | Identifies the visitor; moves anonymous history over. A different stored user is signed out first. | | loginUnidentifiedUser() | Drops the identity (signing out a stored user), fresh anonymous session. | | updateUser(attributes) | Custom attributes agents see on the conversation (≤30 keys, 64-char keys, 512-char values). | | logout() | Revokes the token, removes the push registration, wipes visitor data, rotates the device id, resets the UI. Await it on sign-out. | | present() / presentSpace('home' \| 'messages') / presentConversation(id) / hide() | UI commands. | | getUnreadConversationCount() | Number of conversations with at least one unread reply. | | getUnreadMessageCount() | Total unread replies across conversations (same as UnreadCountDidChange). Before 0.1.10 getUnreadConversationCount() returned this. | | logEvent(name, metadata?) | Analytics event against the visitor. | | setLocale(locale) | Switches the translation set (call after boot). | | addEventListener(event, cb) | Returns { remove() }. | | setStorageAdapter(adapter) | Device id/theme/unread persistence — call before boot. | | setSecureStorageAdapter(adapter) | Keychain/Keystore store for the token + user key — call before boot. | | setPushToken({ token, platform?, bundleId? }) | Manual FCM token for hosts without the Firebase module. |

RepzoWidgetLauncher is an optional floating bubble with an unread badge for apps without their own chat entry point — mount it inside a full-screen container, it positions itself absolutely.

Troubleshooting

  • No push arrives — check in this order: the device registered (a row in Workstation's widget_push_tokens after boot + login), channel provider is FCM with an Apps entry whose bundle id matches, the .p8 is uploaded (iOS), and the app was killed or backgrounded during the test — an open chat never pushes, by design. The SDK logs every push problem with a [repzo-widget] prefix.
  • Push registers nothing and warns "not installed" — the running binary predates the Firebase modules (a JS-only reload isn't enough) or you're in Expo Go. Rebuild the dev client.
  • Notification is silent / doesn't pop — the repzo_widget_messages channel wasn't created before the first push arrived. Note Android locks a channel's settings after creation; uninstall/reinstall to reset while testing.
  • SENDER_ID_MISMATCH in Workstation logs — the app's Google config files and the uploaded service-account key are from different Firebase projects.
  • Widget can't reach a local CRM from a device — tunnel it (ngrok) or use 10.0.2.2 on Android emulators.

Source layout

src/
  index.tsx              public API: RepzoWidget, provider, launcher, types
  api/                   HTTP client and the SSE realtime stream
  push/                  FCM token registration + notification tap handling
  state/                 widget store and the hook that subscribes to it
  ui/
    WidgetProvider.tsx   root: mounts the modal and switches between screens
    screens/             home/ chat/ conversations/ pre-chat/
    components/          rows, avatar, icons, gradients, panel chrome, launcher
    styles/              theme.ts (tokens, color helpers), global.ts
    hooks/  utils/

Releasing

# bump "version" in package.json and SDK_VERSION in src/state/widget-store.ts
npx tsc --noEmit
git commit -am "..." && git tag v0.1.x
git push origin main --tags      # github.com/Repzo
git push public main --tags      # github.com/yousef-repzo (mirror)

Consumers pin the tag, so a release is only "live" for an app after its package.json pin is bumped and dependencies reinstalled.