@repzo/widget-react-native
v1.6.0
Published
Repzo chat widget for React Native
Readme
@repzo/widget-react-native
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: falseturns 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: falseturns "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/messagingin 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-nativeNo 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 installThe 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:
- 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;
- 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 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 onePass 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 withupdateUser()as well. - Same types and limits as
updateUser(). Returnnullor{}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:
- 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);
}
}
});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
onOpenUrlgets it first, so you can routemyapp://orders/7to 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 --latestPatch 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()withpush.enabledand a login both succeeded, the 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.
