@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/messagingin 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#mainExpo: 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:
- 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; - 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;
- 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) // hexPass 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:
- Delivery provider → FCM.
- 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.
- iOS only, one time: upload your APNs
.p8in 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_tokensafter boot + login), channel provider is FCM with an Apps entry whose bundle id matches, the.p8is 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_messageschannel 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_MISMATCHin 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.2on 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.
