@conciergesdk/react-native
v0.1.3
Published
Concierge in-app support chat for React Native and Expo (iOS and Android, with an Expo Go fallback).
Maintainers
Readme
Concierge for React Native and Expo
In-app support chat for React Native apps, on iOS and Android. @conciergesdk/react-native is an Expo module that wraps the native Concierge SDKs (SwiftUI on iOS, Jetpack Compose on Android), so the chat is fully native in dev and release builds. In Expo Go, where native code can't load, it switches to a small pure-JS chat that uses the same API.
- iOS 16.4+ and Android 7 (API 24)+, Expo SDK 58.
- Bare React Native apps should work once Expo Modules is installed (
npx install-expo-modules), but that path is untested so far. - No network calls at
configure. The device registers the first time the chat opens or a push token arrives. - History follows the user across devices once you call
identifywith a signedtoken(see below).
Install
npx expo install @conciergesdk/react-nativeAdd the config plugin to app.json, then build a dev client:
{
"expo": {
"plugins": [["@conciergesdk/react-native", { "push": true }]]
}
}npx expo prebuild
npx expo run:ios # or run:android| Plugin option | Default | Effect |
|---|---|---|
| push | true | iOS: adds the aps-environment entitlement. Android: adds POST_NOTIFICATIONS. |
| apsEnvironment | "development" | Initial aps-environment value. Signing for the App Store switches it to production. |
| backgroundRemoteNotifications | false | Adds UIBackgroundModes: remote-notification. Concierge doesn't need it. |
The plugin never asks for notification permission. Your app decides when to ask.
Quick start
import Concierge, { ConciergeProvider, useUnreadCount } from '@conciergesdk/react-native';
Concierge.configure({ appKey: 'app_YOUR_APP_KEY' });
export default function App() {
return (
<ConciergeProvider>
<Home />
</ConciergeProvider>
);
}
function SupportButton() {
const unread = useUnreadCount();
return <Button title={unread ? `Support (${unread})` : 'Support'} onPress={() => Concierge.open()} />;
}<ConciergeProvider> only matters for the Expo Go fallback (it hosts the JS chat modal). With the native module linked it just renders its children, so keep it in production.
API
| API | What it does |
|---|---|
| configure({ appKey, baseUrl?, storage?, debug?, reviewPromptAfterSupport? }) | Call once at startup. baseUrl defaults to https://api.conciergesdk.com and must be https (plain http only for localhost, 127.0.0.1, ::1, 10.0.2.2 and 10.0.3.2, or any host in a development build; release builds refuse it and the SDK stays off). storage only applies to the Expo Go fallback and defaults to AsyncStorage when it is installed, see below. debug: true shows the debug overlay in development builds, see below. reviewPromptAfterSupport (default true, native only) is covered in Review prompts. |
| identify(userId, { name?, email?, token? }) | Ties the device to your user. Cross-device history needs token: an HS256 JWT your server signs with the app's identity secret, with exp at most 30 days out. An identify without a token stays on this device only. A rejected token emits identityRejected; chat keeps working on this device and the same token is not retried. Call it after sign in and on every launch while signed in. |
| logout() | Forgets the user on this device. The next user never sees the previous chat: the logout is saved before any network call and goes out before anything else, even after an offline logout. Does nothing for guests, so never call it at launch for signed-out users. |
| setMetadata({ plan: 'Pro' }) | String fields shown next to the conversation (up to 50). Replaces the previous set. Never put names or emails here; use identify. |
| breadcrumb('tapped_restore') | Records a step. The last 50 are sent with the next chat request. |
| setContext({ plan: 'pro', screen: 'Paywall' }) | Structured app context (any JSON values) shown in the dashboard customer panel. Merges into what you set before; null removes a key. Well known keys: plan, screen, appState. Up to 50 keys and 8KB. |
| clearContext() | Removes every context key the app set. |
| captureError(error) | Records the newest error (an Error keeps its message, name and stack; a string is the message) so the team sees it next to the conversation. |
| registerTool(name, description, schema, handler, { title?, requiresConsent? }) | An action the AI assistant or your team can ask the app to run. Returns { remove() }. See Client tools. |
| unregisterTool(name) | Removes a tool. |
| registerScreen(id, title, deepLink) / unregisterScreen(id) | Screens support answers can open with a button (knowledge autopilot). See Screens. |
| onOpenScreen((id, deepLink) => ...) | Routes screen buttons with your navigator instead of Linking.openURL. useOpenScreen(fn) and useScreen(id, title, deepLink) are the hook versions. |
| open() / dismiss() | Opens the chat full screen (a sheet on iOS, an activity on Android) / closes it. dismiss() resolves once the chat is gone: await it before showing an Alert from a card handler. |
| <ConciergeChatView onClose? style? /> | The chat as a component, for a tab, a modal or a navigation screen. With onClose it shows a close button. |
| useUnreadCount() | Live unread count of team and AI replies. Once the chat has been opened on the device, it updates live while the app is in the foreground, even with the chat closed, and refreshes when the app returns to the foreground. |
| getUnreadCount() / refreshUnreadCount() | Last known value / fetch it now. |
| useConciergeEnabled() / isEnabled() | false while support is turned off for the app in the dashboard. See Turning support off. |
| registerPushToken(token) | The raw device token: the APNs token (hex) on iOS, the FCM token on Android. Also takes the object from Notifications.getDevicePushTokenAsync() as is. |
| handleNotification(payload, { openChat? }) | Pass push payloads in. Returns true when the push came from Concierge. It updates the badge and, by default, opens the chat. |
| isConciergeNotification(payload) | Checks a payload without side effects. |
| onPaywallCard((paywallId) => ...) | Show your paywall. Called for paywall cards, legacy offer cards and offers that resolve to your paywall. See Cards and callbacks. |
| onOfferCard((redemption) => ...) | Takes over offer cards: you run the purchase. Pass null to get the default handling back. |
| usePaywallCard(fn) / useOfferCard(fn) | The same two callbacks, set while a component is mounted. |
| addEventListener((event) => ...) | Returns { remove() }. Events: chatOpened, chatClosed, messageSent, messageReceived, unreadCountChanged, cardShown, cardTapped, reviewOpened, paywallOpened, paywallUnhandled ({ paywallId }), offerRedeemed, offerFailed, offerUnhandled, purchaseCompleted, purchaseFailed, identityRejected, availabilityChanged ({ enabled }), reviewPromptShown ({ moment }, native only), error. |
| setAppearance({ accentLight, accentDark, title, welcome, showsCloseButton, colorScheme }) | Local overrides (#RRGGBB colors). Anything you leave out comes from the dashboard, then from the defaults. showsCloseButton is iOS only, colorScheme is Android only. |
| maybeRequestReview(moment) | Maybe shows the system review prompt at a good moment. Resolves { shown, reason } and never throws. See Review prompts. |
| isNativeAvailable() | false in Expo Go (the JS fallback is in use). |
| reset() | Wipes the SDK back to a fresh install on this device, without a network request. See Reset. |
| suspend() / resume() / isSuspended() | Makes the SDK invisible and silent until resume(). See Suspend and resume. |
Context, breadcrumbs and the last error are batched and sent in one request, and whatever is still pending rides along with the next message the user sends.
Turning support off
Support can be turned off for the app from the dashboard (a remote kill switch). While it is off the SDK makes no network calls apart from an occasional config check, open() shows nothing and an open chat closes. Queued messages, context and the push token are kept and go out once it is back on. The last known state is saved, so it is right at launch without a request. Hide your Support button while it is off:
import Concierge, { useConciergeEnabled } from '@conciergesdk/react-native';
function SupportButton() {
const enabled = useConciergeEnabled();
if (!enabled) return null;
return <Button title="Support" onPress={() => Concierge.open()} />;
}useConciergeEnabled() re-renders when it changes, Concierge.isEnabled() reads it once, and addEventListener gets an availabilityChanged event with { enabled } on every change. While off, the config is checked at most once every 5 minutes, when the user opens the chat, the app returns to the foreground or the unread count is refreshed (never at launch alone); open() opens the chat if that check says support is back on.
Review prompts
Ask for an App Store or Play rating when the user just got something done, never at launch and never after a complaint:
const { shown, reason } = await Concierge.maybeRequestReview('milestone');Moments: milestone, purchase_completed, content_created, or your own name matching ^[a-z0-9_.:-]{1,64}$ (anything else resolves invalid_moment). onboarding is always refused. The native SDK applies the platform limits before it asks the server: nothing in the first 3 days after install, nothing while the chat is open or the SDK is suspended (suspended), once per app version, at most 3 per 365 days, and at least 90 days (iOS) or 30 days (Android) between attempts. The server then says no after a recent complaint, a negative chat or while a conversation is open. When everything passes it calls StoreKit or Play In-App Review directly, with no question or custom UI first. shown: true means the system API was called; the OS decides whether the prompt actually displays.
With reviewPromptAfterSupport on (the default), closing the chat also asks with the internal support_resolved moment, which the server allows only when the conversation ended on a positive note. Pass reviewPromptAfterSupport: false to configure to turn that off.
Expo Go and web have no system review prompt, so the JS fallback resolves { shown: false, reason: 'unsupported_platform' } without a network call.
Client tools
Register actions the AI assistant (or a team member from the dashboard) can ask the app to run. The user is asked first, then your handler runs and its result goes back to whoever asked.
const sub = Concierge.registerTool(
'resync_library',
'Resync the music library from the server',
{ type: 'object', properties: { full: { type: 'boolean' } } },
async ({ full }) => {
const tracks = await library.resync({ full: full === true });
return { tracks }; // any JSON, up to 8KB
},
{ title: 'Resync your library?' },
);
// later: sub.remove();- Names are 1 to 64 letters, digits,
_or-, starting with a letter. Up to 32 tools.schemais a JSON Schema object for the arguments. - The consent sheet shows your
title(or the description), the tool name and who asked, with Allow and Not now. The text always comes from your registration, never from the server, and so doesrequiresConsent. PassrequiresConsent: falsefor harmless actions. - A logout or a switch to another user cancels running calls and open consent sheets without posting a result.
- The handler has 30 seconds. Throw to report a failure; the message is what the AI or the team sees.
- Calls that arrive while the app is closed wait (two minutes by default) and are picked up when the app comes back.
- The chat shows each call as a small status row ("Resync your library: done").
- You can register tools before
configure. With the native module the native SDK shows the sheet and talks to the server; your JS handler only runs the action. On Android the SDK keeps JS timers running while the native chat covers your app (a headless JS task per call), so handlers can usesetTimeout.
Screens (knowledge autopilot)
With the knowledge autopilot on in the dashboard, AI answers can end with a button that opens the right screen of your app. Tell the SDK which screens exist:
Concierge.registerScreen('settings.backup', 'Backup settings', 'myapp://settings/backup');
Concierge.registerScreen('billing', 'Billing', 'myapp://billing');
// Optional: route with React Navigation or Expo Router instead of Linking.openURL.
Concierge.onOpenScreen((id, deepLink) => router.push(deepLink));- Ids are 1 to 64 letters, digits,
_,.or-, starting with a letter. Titles are 1 to 120 characters. Up to 100 screens. Invalid input is logged and ignored; registering an id again replaces it. - Only ids and titles go to the server (and only while the autopilot is on). Deep links never leave the device: a button carries just the id, and the SDK opens the link you registered for it.
- A button shows only when its id is registered on this device. A tap closes the chat, then calls
onOpenScreenor opens the deep link withLinking.openURL. - You can register screens before
configure. With the native module the native SDK keeps the list and shows the buttons.
Reset
Concierge.reset() puts the SDK back to a fresh install, locally and without any request: it closes the chat and the realtime connection, cancels in-flight work (registration, queued messages, tool calls and consent prompts, without posting a result), deletes the device credentials and the identified user (from the native secure store, or expo-secure-store in the fallback, plus any copy an older build left in AsyncStorage), staged metadata, context, breadcrumbs and errors, the cached history and appearance, the outbox, seen card ids and the unread count (observers get 0). It keeps configure settings, registered tools, your handlers and listeners, local appearance overrides, debug and the suspended state. The next chat open registers a new device. Safe before configure and twice in a row. Returns a promise that resolves once storage is wiped.
Suspend and resume
For screens where support must not exist, such as a decoy vault:
Concierge.suspend(); // invisible and silent
Concierge.isSuspended(); // true
Concierge.resume(); // back to normalWhile suspended the SDK makes no network calls and keeps the socket closed, open() does nothing and an open chat closes, <ConciergeChatView /> renders nothing, useUnreadCount() reports 0, no events reach your listeners, incoming tool calls are left unclaimed (picked up after resume(), or they expire on the server) and consent prompts are dropped without a result. handleNotification still returns true for Concierge pushes but does not open the chat. identify, setMetadata, setContext, breadcrumb, registerPushToken and registerTool keep staging locally, and queued messages stay queued; all of it goes out after resume(), which also restores the real unread count. The state is in memory only, so call suspend() again on the next launch if needed (it works before configure). resume() without suspend() does nothing.
Debug overlay
Concierge.configure({ appKey, debug: true }) shows a small panel in development builds (__DEV__): connection state, the app key (first 12 characters), the identified user, customer and device ids, registered tools, pending tool calls and the last 20 requests with status and timing. It collapses to a pill that starts on the leading edge, vertically centered (clear of the close button and the composer), and can be dragged. With the module linked it is the native overlay; in Expo Go <ConciergeProvider> mounts <ConciergeDebugOverlay /> for you (render it yourself if you don't use the provider). Release builds ignore the flag.
Cards and callbacks
Your team or the AI can attach a card to a message. The chat renders three kinds (title, optional body, one button) and hides unknown ones:
| Card | Tap |
|---|---|
| review | Opens the card's URL (your App Store or Play review page). Only store links open: https on apps.apple.com, itunes.apple.com or play.google.com, itms-apps: and market://details; anything else is never opened. When the link is missing, refused or cannot open, the SDK falls back: the native SDKs ask for a rating in the app (iOS) or open your app's Play Store page (Android); the Expo Go fallback tries the App Store write-review page when the link carries an app id, otherwise it warns and emits an error event. |
| paywall | Closes the chat, then calls onPaywallCard(paywallId). Legacy offer cards that only carry a paywallId do the same. Without a handler the chat still closes, a paywallUnhandled event ({ paywallId }) fires and development builds warn. |
| offer | An Offer Engine offer. The SDK redeems it on the server for this device, then calls onOfferCard(redemption) or runs the default handling (offer code links get the same store check). Past expiresAt the button reads "Offer expired" and does nothing. An optional price ({ label?, amount, original?, period? }) shows as a price row: small caps label, struck through original, the amount, then the period. |
The SDK reports an impression once per card per device (it remembers the last 500 card ids, across launches) and a tap on every tap, so the dashboard can count views, taps and revenue per card. The cardShown event still fires once per card per process.
import Concierge, { type ConciergeOfferRedemption } from '@conciergesdk/react-native';
Concierge.onPaywallCard((paywallId) => navigation.navigate('Paywall', { paywallId }));Default offer handling (no onOfferCard set):
| mechanism.kind | Native iOS | Native Android | Expo Go fallback |
|---|---|---|---|
| product, promotional, win_back | StoreKit 2 purchase with appAccountToken | offerUnhandled event | offerUnhandled event |
| android_offer | offerUnhandled event | offerUnhandled event | offerUnhandled event |
| offer_code | Opens redeemUrl (or the redeem sheet) | Opens redeemUrl | Opens redeemUrl |
| paywall | onPaywallCard(paywallId), else paywallUnhandled | Same | Same |
| comp | "Unlocking..." until the confirmation message | Same | Same |
Running the purchase yourself. Set onOfferCard and the SDK stops after the redeem: it hands you the redemption and runs nothing. Bind the purchase to the card with accountToken so the sale is attributed to the conversation:
- RevenueCat: set the subscriber attribute
concierge_cardtoaccountTokenbefore purchasing. - Google Play Billing:
BillingFlowParams.newBuilder().setObfuscatedAccountId(accountToken). - StoreKit:
appAccountToken(accountTokenis a UUID string).
import { Alert, Linking } from 'react-native';
import Purchases from 'react-native-purchases';
Concierge.onOfferCard(async (redemption: ConciergeOfferRedemption) => {
const m = redemption.mechanism;
try {
await Purchases.setAttributes({ concierge_card: redemption.accountToken });
switch (m.kind) {
case 'product':
case 'win_back':
return await buyProduct(m.productId);
case 'promotional':
return await buyWithPromoOffer(m.productId, m.offerId, m.signature);
case 'android_offer':
return await buyPlayOffer(m.productId, m.basePlanId, m.offerId);
case 'offer_code':
return await Linking.openURL(m.redeemUrl);
case 'paywall':
return showPaywall(m.paywallId); // the SDK closed the chat already
case 'comp':
return; // granted on the server, a confirmation message follows in the chat
}
} catch (e: any) {
if (e?.userCancelled) return; // RevenueCat: the customer closed the store sheet
// Store purchases run over the open chat. Close it, then say what went wrong
// (BILLING_UNAVAILABLE, no network, product not found), or the tap looks like it did nothing.
await Concierge.dismiss();
Alert.alert('Purchase failed', e?.message ?? 'Please try again later.');
}
});
const m = redemption.mechanism;
switch (m.kind) {
case 'product':
case 'win_back':
return buyProduct(m.productId);
case 'promotional':
return buyWithPromoOffer(m.productId, m.offerId, m.signature); // signature null: RevenueCat signs it
case 'android_offer':
return buyPlayOffer(m.productId, m.basePlanId, m.offerId);
case 'offer_code':
return Linking.openURL(m.redeemUrl);
case 'paywall':
return showPaywall(m.paywallId);
case 'comp':
return; // granted on the server, a confirmation message follows in the chat
case 'unknown':
console.warn(`Update the Concierge SDK to handle ${m.originalKind}`);
}
});ConciergeOfferRedemption is { cardId, offerId, mechanism, accountToken, expiresAt }, and mechanism is a union you switch on kind. Redeem errors show a short message on the card (410 expired, 422 no longer available) and emit offerFailed on iOS and in the fallback. purchaseCompleted and purchaseFailed only come from the native iOS StoreKit default.
On the native SDKs the handlers are installed only while JS has one set, so the iOS StoreKit default stays on until you call onOfferCard.
Link purchases to customers
The refund desk, purchase receipts, renewal heads ups and win-back history only know a purchase belongs to a customer through what the store signs. What the app reports with setContext (an originalTransactionId or purchaseToken) is never used, because any device could send someone else's. Two ways to link:
- RevenueCat (simplest): set the app user id to the same id your server signs into the identity token,
Purchases.logIn(user.id). - Your own purchases: pass the customer's account token as
appAccountToken(iOS) and the obfuscated account id (Android). It is derived from the verified user id: a UUID user id is used as is, anything else becomes its name based UUID v3, the same value as Java'sUUID.nameUUIDFromBytes(userId.toByteArray(Charsets.UTF_8)). Theuuidpackage'sv3mixes in a namespace and gives a different value, so use any MD5 that returns bytes:
import { Platform } from 'react-native';
import { md5 } from 'js-md5';
import { requestPurchase } from 'react-native-iap';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function conciergeAccountToken(userId: string): string {
if (UUID_RE.test(userId)) return userId.toLowerCase();
const b = md5.array(userId); // MD5 of the UTF-8 bytes
b[6] = (b[6] & 0x0f) | 0x30;
b[8] = (b[8] & 0x3f) | 0x80;
const h = b.map((x) => x.toString(16).padStart(2, '0')).join('');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
const token = conciergeAccountToken(user.id);
await requestPurchase(
Platform.OS === 'ios'
? { sku, appAccountToken: token }
: { skus: [sku], obfuscatedAccountIdAndroid: token },
);Offer card purchases keep using redemption.accountToken; it takes priority for that purchase and still links it to the customer. Unverified identifies never match. The dashboard's Money page (Settings tab) warns "Purchases can't be matched to customers yet" when no store event in the last 90 days carried an account token or a RevenueCat app user id.
Push notifications
Concierge sends APNs pushes on iOS and FCM pushes on Android. Forward the token and route taps. With expo-notifications:
import * as Notifications from 'expo-notifications';
import Concierge from '@conciergesdk/react-native';
// After your app has asked for permission:
const token = await Notifications.getDevicePushTokenAsync();
Concierge.registerPushToken(token); // never the Expo push token
Notifications.setNotificationHandler({
handleNotification: async (n) => {
const data = n.request.trigger && 'payload' in n.request.trigger ? n.request.trigger.payload : n.request.content.data;
const ours = Concierge.handleNotification(data, { openChat: false });
const show = !(ours && Concierge.isChatVisible());
return { shouldShowBanner: show, shouldShowList: show, shouldPlaySound: show, shouldSetBadge: false };
},
});
Notifications.addNotificationResponseReceivedListener((r) => {
const t = r.notification.request.trigger;
Concierge.handleNotification(t && 'payload' in t ? t.payload : r.notification.request.content.data);
});On iOS the Concierge fields arrive in a concierge dictionary; on Android they are flat FCM data strings (source: "concierge"). handleNotification accepts both.
Expo Go fallback
Expo Go can't load third-party native code, so the module isn't there. The SDK notices, logs one dev warning, and runs a small JS client against the same HTTP and WebSocket API:
- Text messages, photos, history paging, typing, Delivered and Read, review, paywall and offer cards (redeem, expired, busy and inline errors), retry and delete for failed messages.
- Photos: up to 4 per message from the library or the camera, downscaled to 2048 px on the longest side and saved as JPEG at 0.72 (the iOS SDK's limits). Each one uploads to
/v1/sdk/uploadswith a progress bar on the bubble, then the message is posted with the uploaded attachments. Draft photos have a Remove photo button. Received photos show in the bubble; tap one for a full screen viewer with pinch zoom. - Persistent outbox: every message (and its photos) is queued in storage before anything goes on the wire, so it survives an app kill. Messages go out one at a time, in the order they were written. Each keeps its client generated
clientIdthrough every retry and restart, so the server never stores duplicates, and photos that already uploaded are not uploaded again. Pending context rides along with the next message and is cleared only once that send succeeds. - Retries: offline, timeouts, 5xx, 408 and 429 park the queue and retry after 2, 4, 8, 16, 32, 60 and 60 seconds (shown as Waiting for connection, or Sending soon while a 429
Retry-Afteris waited out), then the message needs a tap. The queue also flushes right away when the WebSocket connects, when the app returns to the foreground, when the chat opens, when you send another message, and on the next launch. A rejection (other 4xx, for example413for a photo over 10 MB) marks that message Not delivered and lets later ones go. - Each queued message is stamped with the user who wrote it. A message from a user who has since logged out (or been replaced by another user) is dropped, never sent; messages written before a login still go out. Logout clears the queue.
- The WebSocket is open while the chat is on screen, and in the foreground once the chat has been opened on the device or tools are registered (live unread: with the chat closed it only applies unread counts and tool calls). It closes in the background, pings every 25 seconds, reconnects with backoff, and resumes from the last message. The app key and device token go in headers, not the URL. A
4401close makes the device register again. - Client tools work too: the consent sheet is a React Native
Alert, calls are deduped by id and caught up on every reconnect and foreground. Tools are advertised once the device has registered (the first chat open or push token).
Install the optional packages the fallback uses. All of them ship in Expo Go, so no dev build is needed:
npx expo install @react-native-async-storage/async-storage expo-secure-store expo-image-picker expo-image-manipulator| Package | Without it |
|---|---|
| @react-native-async-storage/async-storage | The outbox and cached history live in memory: unsent messages are lost on a kill. |
| expo-secure-store (recommended, required to keep the device across launches) | The device token and the signed in user id are kept in memory only, never in plaintext AsyncStorage (which lands in device backups): every launch registers a new device, an anonymous customer starts over, and a signed in user is linked again by identify. A development build warns once. With it they live in the Keychain or Keystore (this device only, readable after first unlock). A token an older SDK left in AsyncStorage is used once and deleted (moved to the secure store when there is one). |
| expo-image-picker | No attach button. |
| expo-image-manipulator | Photos are still compressed by the picker, but not downscaled. |
AsyncStorage is picked up on its own. To store elsewhere, pass anything with getItem, setItem and removeItem:
Concierge.configure({ appKey: 'app_...', storage: myStorage });For the camera, add an NSCameraUsageDescription (the expo-image-picker config plugin does it) in your dev or release build; Expo Go already has one.
Fallback vs native
Known differences that remain. Use a dev build for the native chat.
| Area | Native SDKs | JS fallback |
|---|---|---|
| Cached history | History is cached on disk and shown offline | Only the outbox is persisted; history needs the network |
| Offline banner | "Offline. Messages will send when you're back online." | "Connecting…" while the socket reconnects |
| Photo files | Copied into the SDK's own outbox directory | The picker or manipulator cache file is referenced; if the OS purges it before the upload, the message fails after its retries instead of at once (attachment_missing on iOS) |
| Photos per message | iOS: 4, 2048 px, JPEG 0.72. Android: 1, 1600 px, JPEG 0.82 | 4, 2048 px, JPEG 0.72 on both platforms |
| Photo source | Photo library only | Photo library or camera |
| Retry budget | iOS: 8 attempts, then a tap. Android: retries until it succeeds (1 to 60 seconds) | iOS schedule on both platforms |
| Zoom | System zoom (iOS) and Compose gestures (Android) | iOS: zooming scroll view. Android: pinch, drag and double tap, no fling |
| Message actions | Context menu with Try Again, Delete and Copy | Tap a failed message to retry, long press to delete, no Copy |
| Offer purchases | iOS buys product, promotional and win_back with StoreKit 2 by default | No billing: store offers need onOfferCard, otherwise offerUnhandled |
| Typing without a socket | Falls back to POST /v1/sdk/typing | Sent only over the WebSocket |
| Tool consent | The SDK's own consent sheet | A React Native Alert |
| Review prompts | StoreKit and Play In-App Review through maybeRequestReview | Not available (unsupported_platform) |
How the wrapping works
- iOS:
ios/ConciergeReactNative.podspeccompiles the Expo glue (ios/*.swift) and links the prebuiltConcierge.xcframeworkthat ships inside this package (device and Simulator slices, built with library evolution, strings and privacy manifest included). The glue imports it as theConciergemodule. - Android:
android/build.gradlecompiles the Expo glue (com.conciergesdk.reactnative) and depends on the AAR from Maven Central (com.conciergesdk:concierge), which brings Compose, OkHttp, coroutines, its manifest (INTERNET, the chat activity) and its own R8 rules. Compile the app against Android SDK 37. - JS: the package ships compiled, minified JS with
.d.tstypes. Each call goes to the native module (requireOptionalNativeModule('Concierge')) or, when that's missing, to the pure JS fallback.
Developing this package (Concierge monorepo only)
Inside the monorepo the native SDKs build from source: the podspec copies sdk/ios/Sources/Concierge into ios/Vendor (gitignored) and compiles it into the pod, and android/build.gradle compiles sdk/android/concierge/src/main into the module. Set CONCIERGE_NATIVE=binary (or -PconciergeNative=binary for Gradle) to build against the binaries instead. Release steps: docs/RELEASING.md.
example/ is an Expo app that points at a local API (http://localhost:4000, http://10.0.2.2:4000 on the Android emulator) with the seeded demo key app_demo000000000000000000.
npm install && npm test && npm run typecheck
cd example && npm install && npx expo prebuild && npx expo run:ios # or run:android
npm run build # minified JS and types into build/License
Free to use with a Concierge account under the Concierge SDK License. See LICENSE.
