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

Published

Repzo chat widget for React Native

Readme

@repzo/widget-react-native

npm version npm downloads license platforms Expo Go

The Repzo chat widget for React Native — an Intercom-style messenger that drops into any app. Pure JavaScript/TypeScript, apart from one small optional native piece (the voice message player, linked automatically in Expo apps): no config plugin and nothing extra to set up. Works on iOS and Android, Expo and bare React Native, old and new architecture.

It connects to the same channel as your Repzo website chat (Workstation → Settings → Inbox Channels → Website Chat), so a message sent from the app lands in your Repzo Inbox and agent replies arrive live.

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)
  • Sent/Seen receipts, tap to retry a message that didn't send (1.3.1+), teammate avatars and presence, deleted-message placeholders
  • 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
  • Image viewer with pinch and double-tap zoom for photos in the conversation; swipe between a message's photos (1.4.0+)
  • Photo albums: several photos in one message share one square, WhatsApp-style (1.4.0+; photoAlbums: false turns them off)
  • Conversations open on the newest message, load older ones as the visitor scrolls up, and offer a jump-to-latest button (1.4.0+)
  • Team replies keep their images where the teammate put them, between the text (1.4.0+)
  • Conversation lists say "You:" before the visitor's own last message, and "Photo" or "File" for one without words (1.4.0+; youPrefix: false turns "You:" off)
  • Photo and file attachments (optional — with expo-image-picker / expo-document-picker, or your own picker)
  • Voice messages play in the chat, with the phone's own audio player — nothing to install in Expo apps
  • In-app notification cards for new replies while the messenger is closed
  • Posts from Workstation over the app, with replies or emoji reactions
  • Push notifications via FCM (optional — needs @react-native-firebase/messaging in the host app), including Mobile Push campaigns from Workstation with images
  • Conversation ratings after a teammate closes the chat
  • Attributes updated with each message: where the visitor is or what they're doing, refreshed whenever they send a message instead of on a timer (1.6.0+; boot({ messageAttributes }))
  • A welcome message as the first message of a new chat, if you turn it on in Workstation (1.6.0+)
  • 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 | | expo-image-picker | optional — "Photo library" and "Take photo" attachments (v16+ / SDK 52+) | | expo-document-picker | optional — "File" attachments | | expo-file-system | optional — reads picked files as base64 (falls back to fetch without it), and (1.4.0+) lets large attachments upload straight from storage (recommended) | | expo-image-manipulator | optional — shrinks photos so camera shots fit the 1.5 MB attachment limit | | expo-audio | optional (1.2.2+) — no longer needed from 1.3.0+ in apps with Expo modules (see "Voice messages"); still used where the SDK's own player isn't built in | | expo-crypto | optional (1.1.0+) — secure random source for the anonymous device id when the runtime has no crypto global (see below) |

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

1.1.0+. The anonymous device id needs a secure random source. Hermes has no crypto global, so install expo-crypto (Expo), or react-native-get-random-values imported at the top of index.js (bare React Native). Without either, the id falls back to Math.random and development builds log a warning.

Installation

Published on npm as @repzo/widget-react-native:

# npm
npm install @repzo/widget-react-native

# Yarn
yarn add @repzo/widget-react-native

# pnpm
pnpm add @repzo/widget-react-native

# Expo
npx expo install @repzo/widget-react-native

No Metro change is needed with the standard Expo or React Native Metro config — see Metro setup if you build yours from bare metro-config.

Recommended: add async-storage so sessions survive app restarts (without it, or a custom adapter, they only last until the app is killed):

# Expo
npx expo install @react-native-async-storage/async-storage

# Bare React Native
npm install @react-native-async-storage/async-storage && cd ios && pod install

The other optional peers are react-native-safe-area-context (safe-area insets — most apps already have it) and react-native-linear-gradient (gradient home backgrounds; a native module, so not in Expo Go — without it the SDK draws a banded approximation). Push notifications need Firebase, see Push notifications.

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

To upgrade later, see Upgrading.

Metro setup

Nothing to do for most apps. Every optional integration is loaded with a require() inside a try block, and the default Expo (expo/metro-config) and React Native (@react-native/metro-config) configs enable Metro's allowOptionalDependencies, so a missing integration simply turns into a require that fails at runtime and is caught.

Only if your config starts from bare metro-config defaults (where allowOptionalDependencies is off) wrap it with withRepzoWidget, which just turns that flag on:

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

module.exports = withRepzoWidget(yourMetroConfig);

Earlier versions of this helper installed a custom resolver; that broke Expo development builds at startup (Property 'MessageQueue' doesn't exist). Upgrade, or remove the wrapper.

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
  // Optional — the user's account in your app. Matched by id, so a rename
  // updates the same CRM account; the app's values win for fields it sends.
  account: { id: user.account.id, name: user.account.name },
  signedUpAt: user.createdAt, // optional — shown as "Signed up" in the inbox
});

// 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. Pass baseUrl only if Repzo gave you a different environment to connect to. baseUrl must be https://; boot() throws for plain http:// (local addresses in __DEV__ builds excepted).

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 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 live connection, 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, userId ?? email)   // hex — sign the userId when you have one

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). From 1.1.0, development builds log a one-time warning when the token is about to be stored without a secure adapter.

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, posts 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.

1.1.0+. Link text can't disguise where a link goes: when the text of a link reads as a web address on a different site than the one it opens, the widget shows the real address instead, so [yourbank.com](https://other.example) can't pass for your bank. Ordinary link text ("Book here") is shown as written.

Attachments

Visitors can send photos and files from the + button beside the message field. The SDK has no picker of its own; it uses whatever your app installs:

npx expo install expo-image-picker expo-document-picker expo-file-system expo-image-manipulator

| Installed | Menu options | | --- | --- | | expo-image-picker | Photo library, Take photo | | expo-document-picker | File | | expo-file-system | nothing new — reads picked files faster (SDK 53's readAsStringAsync and SDK 54+'s File both work), and (1.4.0+) uploads large files straight from storage. Without it a file is read into memory before it uploads, which can fail on Android for files over about 30 MB (recommended) | | expo-image-manipulator | nothing new — shrinks photos so camera shots fit the size limit (recommended) |

With none of them (and no pick below) the button isn't shown. It is also hidden when attachments are switched off for the channel in Workstation. These are Expo modules: they run in Expo Go, and bare React Native apps need expo installed to use them. Taking photos needs NSCameraUsageDescription in Info.plist (the expo-image-picker config plugin adds it).

Limits — the server's, enforced before anything is sent so the visitor gets a clear message instead of a silently missing file:

  • 5 files per message
  • 1.4.0+. 50 MB per file, the cap Workstation sets. Each file starts uploading as soon as it's picked, with a spinner on its thumbnail, and Send turns on once every file is up, so the message then goes out at once. A file that fails to upload shows Tap to retry; removing it cancels its upload. A file picked about an hour before Send uploads again as the message goes out, since Workstation stops keeping an unsent upload by then.
  • Before 1.4.0, or with an older Workstation that doesn't take uploads, files travel base64-encoded inside the message instead: 1.5 MB per file, and 6 MB for all files in one message together.

1.4.0+. To upload the files only when the visitor taps Send, so nothing leaves the phone for a file they pick and then remove, pass attachments: { uploadOnPick: false }. Send then stays on while files are attached, and the message goes out once its files are up.

With expo-image-manipulator installed, photos are scaled to at most 1600 px on the longest edge and saved as 70% JPEG — typically 200–500 KB, so they upload quickly and fit the 1.5 MB cap of an older Workstation. Without it they're only compressed. A message can be attachments only, with no text. If a send fails, the attachments stay in the composer so the visitor can retry.

1.3.1+. Visitors can open what they sent with a tap. A photo opens in the full-screen viewer right away, from the copy on the phone. A PDF or other file opens in the phone's viewer once Workstation has stored it, usually a moment after it's sent.

1.4.0+. When a message has several photos, sent or received, they show together as an album in one square, WhatsApp-style, with "+N" on the last tile when there are more than four. Tapping one opens them all in the viewer: swipe sideways to move between them (the direction follows the app's language), and the header shows which one is open. To show each photo on its own, full width, as before, pass photoAlbums: false:

await RepzoWidget.boot({ channelAccountId: '…', photoAlbums: false });

Tapping a photo still opens the message's photos in the viewer.

Your own picker

Apps that use react-native-image-picker, react-native-document-picker or anything else can plug it in. pick replaces the Expo pickers entirely; return the files (base64 or a readable file:// uri), or null on cancel:

import { launchImageLibrary } from 'react-native-image-picker';

await RepzoWidget.boot({
  channelAccountId: '<widget id>',
  attachments: {
    sources: ['photo'],            // menu options to offer (default: all three)
    pick: async (source) => {      // 'photo' | 'camera' | 'file'
      const result = await launchImageLibrary({ mediaType: 'photo', includeBase64: true, quality: 0.7 });
      if (result.didCancel || !result.assets) return null;
      return result.assets.map((asset) => ({
        name: asset.fileName ?? 'photo.jpg',
        type: asset.type ?? 'image/jpeg',
        size: asset.fileSize,
        uri: asset.uri,
        base64: asset.base64,
      }));
    },
  },
});

The limits still apply to whatever pick returns. To turn attachments off in the app regardless of the channel setting, pass attachments: { enabled: false }.

Voice messages

1.2.2+. Voice messages from your team, and any other audio file in the conversation, play right in the chat — a play button, a waveform to tap or drag through, and 1×/1.5×/2× speed.

1.3.0+. The SDK brings its own small player, built on the phone's audio player (AVPlayer on iOS, MediaPlayer on Android), so there's nothing to install. In an app with Expo modules (any Expo app, or bare React Native with expo installed) it's linked automatically the next time you build the app (a new development build or store build; an over-the-air update alone isn't enough). It adds no permissions and no other libraries.

Where the SDK's player isn't built in — Expo Go, bare React Native without Expo modules, or an app binary built with 1.2.2 or older — notes play with expo-audio if your app has it, and otherwise show as a file the visitor taps to open in the browser, as before. A note only downloads once the visitor taps play, starting one pauses any other, and a note that can't be played falls back to that file.

With the SDK's player, notes play like in messaging apps: on iOS they play with the ringer switch on silent, and they pause music while they play and let it carry on after. A note pauses when the visitor leaves the app, and when a phone call, a navigation prompt or another app's audio starts. If your app has set its own iOS audio session category (including ambient), the player keeps it.

With expo-audio instead, the widget leaves your app's audio session alone, so notes follow your expo-audio settings. On iOS that means they're muted while the ringer switch is on silent, unless your app calls setAudioModeAsync({ playsInSilentMode: true }) from expo-audio.

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.

1.5.0+. When Workstation answers that it's busy (HTTP 429), the error's retryAfter holds the seconds it asked to wait, when it said.

Custom attributes

updateUser() saves attributes on the visitor so agents see them on the conversation, for example a plan, a role or which part of your app they're in:

await RepzoWidget.updateUser({ plan: 'pro', role: 'manager' });

Up to 30 keys, keys up to 64 characters, values (text, numbers or true/false) up to 512 characters. Each call only changes the keys it sends; the others keep their saved values.

1.5.0+. Calls are paced so an app can't flood Workstation:

  • Values Workstation already has aren't sent again.
  • Up to 20 updates go out at once, then one every 90 seconds (20 per 30 minutes).
  • Calls in between are merged, newest value per key, and sent on the next allowed update. They resolve right away. The latest value of every attribute still arrives.
  • When Workstation answers that it's busy, the SDK waits as long as it asks and sends again. After a failed connection or a server error it tries again a minute later, and the call that failed still rejects.

Send what agents need to see, not live data. A value that changes every few seconds, such as a GPS position, only arrives about every 90 seconds once the first 20 updates are used. boot({ throttleUpdateUser: false }) sends every call straight away, as before 1.5.0.

Attributes updated with each message

1.6.0+. For values that change often, such as where the visitor is, pass messageAttributes to boot() instead of calling updateUser() on a timer. The SDK calls it when the visitor taps Send and sends the result with that message, in the same request:

await RepzoWidget.boot({
  channelAccountId: '<widget id>',
  messageAttributes: () => ({
    location: `${position.latitude},${position.longitude}`,
    inVisit: true,
  }),
});
  • Workstation saves them on the visitor, as updateUser() would, so agents see the values from the visitor's latest message in their details. You don't need to send the same values with updateUser() as well.
  • Same types and limits as updateUser(). Return null or {} to send none.
  • It may be async, but a message waits for it at most 1 second. If it takes longer or throws, the message is sent without attributes.
  • Sending a failed message again reuses the values read when Send was first tapped.
  • Workstation deployments without this feature send the message as usual and ignore the attributes.

Conversation list

1.4.0+. On Home and in Messages, each conversation shows its newest message. When the visitor sent it, "You: " comes first (Arabic "أنت: "), and a message with no words shows as "Photo" or "File" (Arabic "صورة", "ملف"). Workspaces can change these with the lastMessageYou (keeps {message}), lastMessagePhoto and lastMessageFile translation keys.

Workstation's list doesn't say who sent the newest message, so the SDK remembers it from what this device sent, received or loaded, across launches too. A conversation last written in on another device shows no "You:". To show the message without "You:", as before 1.4.0:

await RepzoWidget.boot({ channelAccountId: '…', youPrefix: false });

Conversations in the MessageDidArrive payload carry the same information in two optional fields: lastMessageFromVisitor (true when the visitor sent the newest message) and lastMessageAttachment ('photo' or 'file' when it had no words, otherwise null). snippet stays text, worded the way Workstation's list words it ("2 attachments"). Both fields are filled in whether or not youPrefix is on.

In-app notifications

When a team reply arrives while the app is open but the messenger is closed, a card slides up at the bottom of the screen with the agent's avatar, the message and "Reply from {agent}". It stays until the visitor:

  • taps it — opens that conversation, or
  • swipes it left or right — dismisses it (the thread stays unread, so the badge keeps counting it).

Only the newest reply is shown; it also goes away by itself when the messenger opens or the thread is read on another device. Replies that arrived while the app was closed get the same card once when the app starts (newest unread thread only), so visitors without push still notice them. Screen readers announce it and offer a "Dismiss" action. It is on by default and needs <RepzoWidgetProvider> mounted at the root; to turn it off:

await RepzoWidget.boot({ channelAccountId: '…', inAppNotifications: false });

While the app is in the background the system push notification (below) does this job instead.

Posts

1.2.0+. Posts made in Workstation under Send → Post with Show on: Mobile appear over the app, the same way they do on the website:

  • Badge — a small pill above the bottom edge with the sender and a dot.
  • Snippet — a card with the sender and the first words.
  • Full — opens right away.

Tapping a badge or snippet opens the post: a sheet over the app for Small posts, a full screen for Large ones. It shows paragraphs, lists, code, a button and social links, and ends with a reply box or emoji reactions, depending on the post's Reply type. Replies show under the post and also go to an inbox conversation of their own, with the post on top, where your team answers. The team's answers show under the post too, live, like a chat, with a Continue in Messenger link; they also reach the messenger as usual. Answers read under the open post count as read. The post shows text only, so an answer with only attachments stays unread (badge and in-app card) until the visitor opens the conversation in the messenger. Tapping a reaction answers the post: the emoji pops, the post says thanks, and a moment later it goes (tapping another emoji in that moment changes the pick).

⌄ — or swiping the post's top edge down, tapping outside a sheet, or Android's back button — minimizes it: it goes back to its pill (or snippet card) and stays there, on later launches too, until the visitor opens it again. × closes the post for good, and the next post in line (if any) shows.

One post shows at a time. The SDK asks for it at start-up, on login/logout and when the app comes back to the foreground. The post steps aside while the messenger is open. With Add push notification on mobile turned on, Workstation pushes the post once to each app user it's for when it goes live (only to apps on 1.2.0 or later, which can show it). Tapping the push opens the post, closing the messenger first if it's open. This needs push notifications set up (below).

Links and buttons in a post go through the same rules as links in messages (see "Links in messages"), so your onOpenUrl can route a post button to a screen in your app (myapp://…).

On by default; needs <RepzoWidgetProvider> mounted at the root. To turn posts off:

await RepzoWidget.boot({ channelAccountId: '…', posts: false });

Welcome message

1.6.0+. In Workstation, an inbox's welcome message can be set to go out as soon as a chat opens. A new conversation then starts with it, before the visitor types anything, and your team sees it above the visitor's first message.

There's nothing to set up in the app. Apps on 1.5.1 or older get the same welcome right after the visitor's first message.

Conversation ratings

1.0.6+. Two minutes after a teammate closes a conversation, Workstation can post a rating card in the thread: five faces from Terrible to Amazing. Tapping a face saves it right away. After a bad rating the visitor can pick a reason, labelled in their language, and leave a comment. A comment locks the rating. The card says so once the rating can no longer change.

Workstation sends the card, so there's nothing to set up in the app. Apps on 1.0.5 or older show it as a plain system line that can't be answered.

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 live 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);
    }
  }
});

Mobile Push campaigns

1.2.1+. Workstation's Send → Mobile Push sends a push notification to the people who use your app: a title, a message, an optional image, and what a tap does. The person picks iOS, Android or both; each device is sent to based on the platform it registered with, so there's nothing to set up for that.

  • Tapping opens the app. When the push has a link, the SDK opens it through the same rules as links in messages (see "Links in messages"): your onOpenUrl gets it first, so you can route myapp://orders/7 to a screen; otherwise only web, mail and phone links open. The tap also counts as an open in the campaign's stats.
  • Images show on Android with nothing to add. On iOS they need a Notification Service Extension in your app that downloads the image (with Firebase, the extension calls FIRMessagingExtensionHelper.populateNotificationContent). Without one, iPhones show the push as text.

Apps on 1.2.0 or older still receive these pushes; tapping one just opens the app, and the tap isn't counted as an open.

Your app reads notification taps too?

1.2.1+. Firebase gives the notification that launched the app to the first caller of getInitialNotification(). If your app calls it (or re-posts pushes locally while it's open), widget pushes reach your code instead of the SDK and open nothing. Hand every tapped notification to the SDK first:

const openTapped = (message: { messageId?: string; data?: Record<string, unknown> }) => {
  if (RepzoWidget.handlePushNotification(message)) return; // a widget push — the SDK opens it
  openMyScreen(message.data); // your app's own pushes
};
getInitialNotification(messaging).then((message) => message && openTapped(message));
onNotificationOpenedApp(messaging, openTapped);

A tap that arrives before boot() finishes waits for it. Passing the messageId lets the SDK skip a tap it already saw through its own listener.

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. 1.4.0+: conversation.lastMessageFromVisitor and conversation.lastMessageAttachment say who sent the newest message and whether it was only photos or files (see "Conversation list"). | | 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?, attachments?, inAppNotifications?, posts?, photoAlbums?, youPrefix?, throttleUpdateUser?, messageAttributes? }) | Mints/resumes the session and connects the live stream. Idempotent; safe to call repeatedly. 1.6.0+: messageAttributes updates attributes with each message, see "Attributes updated with each message". | | loginUserWithUserAttributes({ userId?, email?, name?, phone?, userHash?, account?, signedUpAt? }) | Identifies the visitor; moves anonymous history over. account: { id, name?, website? } links them to a CRM account matched by id; signedUpAt (Date, unix seconds or ISO) shows as "Signed up". 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). 1.5.0+: paced, see "Custom attributes". | | 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. | | handlePushNotification({ messageId?, data? }) | 1.2.1+. Hand over a notification tap your app read itself; true when it's a widget push (see "Your app reads notification taps too?"). |

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.

Upgrading

npm install @repzo/widget-react-native@latest
# or
yarn upgrade @repzo/widget-react-native --latest

Patch and minor releases don't need code changes in your app. When a new feature shows up by default, it comes with an opt-out, listed below. Anything breaking waits for a major release and comes with step-by-step upgrade notes. Apps built with an older version keep working as Workstation adds features. They just don't show the new ones until you update.

Every release, and what to do when you move to it, is in CHANGELOG.md, which ships inside the package (node_modules/@repzo/widget-react-native/CHANGELOG.md). Versions that need action from you:

| Upgrading from | What to do | | --- | --- | | 0.1.x (installed from git) | Install from npm and go straight to 1.0.1 or later. 0.1.10 also changed getUnreadConversationCount(), link handling and http:// base URLs; see the changelog. | | 1.0.0 | In-app notification cards are now on by default (inAppNotifications: false turns them off). The attach button appears if expo-image-picker or expo-document-picker is installed (attachments: { enabled: false } hides it). The withRepzoWidget Metro wrapper is optional now. | | 1.0.9 or older | Nothing required. Development builds now warn once if the visitor token isn't in secure storage, or if the device has no secure random source — see "Secure token storage" and "Requirements". | | 1.1.0 or older | Posts from Workstation now show over the app by default, once one is set live for Mobile (posts: false turns them off). They need <RepzoWidgetProvider> mounted at the root. | | 1.2.2 or older | Nothing required. In apps with Expo modules, the SDK now includes a small native voice player, so build the app again (development or store build) to get it; expo-audio can be removed if nothing else in your app uses it. If your runtimeVersion uses the fingerprint policy, it changes with this update. | | 1.3.1 or older | Nothing required. With a Workstation that takes uploads, a picked file now starts uploading right away, before the visitor taps Send (attachments: { uploadOnPick: false } uploads on Send instead). Several photos in one message now show as an album (photoAlbums: false shows each on its own, as before). Home and Messages put "You:" before the visitor's own last message (youPrefix: false turns it off). | | 1.4.0 or older | Nothing required. updateUser() is now paced: unchanged values are skipped, and past 20 updates in 30 minutes calls are merged and sent later (throttleUpdateUser: false sends every call, as before). See "Custom attributes". |

Troubleshooting

  • No push arrives — check in this order: boot() with push.enabled and a login both succeeded, the 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.