# React Native SDK `@hatang/chat-react-native`

> **Info: Version**
>
> `@hatang/chat-react-native` **0.4.2** · protocol v1 · React Native 0.76+ (New Architecture or not) · Expo SDK 52+ · React 18+ · [npm](https://www.npmjs.com/package/@hatang/chat-react-native) · [Compatibility](https://hatang.dev/docs/guides/sdks/compatibility.md)

> **Note**
>
> Version 0.3 has the chat screen, the visitor's conversation inbox, hooks for your own UI, signed identity, photo and file attachments, product, order and payment cards with in-app purchase hooks, push notifications through your own Firebase project, conversion events, deep links that restore a session, and a chat screen that follows the site's widget settings. The API may still change before 1.0.

Support chat for React Native and Expo apps. The SDK talks the [realtime protocol](https://hatang.dev/docs/reference/realtime-protocol.md) directly to the Ha Tang gateway (no web view), like the [Flutter](https://hatang.dev/docs/guides/sdks/flutter.md), [iOS](https://hatang.dev/docs/guides/sdks/ios.md) and [Android](https://hatang.dev/docs/guides/sdks/android.md) SDKs. The UI is built entirely from React Native core components and the package has no native code of its own; Expo modules (secure storage, device info, image and file pickers) are optional and loaded when installed. Customers who write from your app land in the same inbox as visitors from your website, Zalo, Messenger, Instagram and TikTok Shop.

## Installation {#installation}

### Requirements

| | Version |
|---|---|
| React Native | 0.76+ (New Architecture or the old architecture) |
| Expo | SDK 52+ (managed or bare) |
| React | 18+ |

### Get an app key from the dashboard

The SDK needs two values: the **Site ID** and an **app key** (`hak_…`). The key replaces the browser's Origin check: the app sends it on start, so the gateway knows which app the session comes from.

1. In the dashboard, open **Support → Channels → Add channel → Mobile app**.
2. Click **Create app key**: give it a name (for example "Acme RN"), pick the platform, and optionally add bundle ids.
3. The key is shown once. Pass it to `Hatang.configure({ appKey })`.

The key is public like the site id (it ships inside your build) but it can be revoked. To rotate it safely, create a new key, ship an update that uses it, then revoke the old one. Sessions that use a revoked key are cut off within a minute.

### Add the package

```bash
# Expo: the package and the optional modules, at the versions that match your Expo SDK
npx expo install @hatang/chat-react-native expo-secure-store expo-application expo-device expo-image-picker expo-document-picker react-native-safe-area-context

# Bare React Native
npm install @hatang/chat-react-native
# or
pnpm add @hatang/chat-react-native
```

### Optional Expo modules

Every `expo-*` package is an **optional peer dependency**. The SDK loads them at runtime when they are installed; if one is missing, the matching feature is turned off and the app still runs:

| Package | Used for | When it is missing |
|---|---|---|
| `expo-secure-store` | The visitor token in the Keychain / Keystore, and a few small session settings | Kept in memory: the visitor is forgotten every time the app restarts, and the SDK logs a warning. Pass `storage` instead (see [Storage](#storage)). |
| `expo-application` | The app version (`1.4.0+12`) | No app version is sent |
| `expo-device` | Device model and OS version | Uses `Platform.Version`, no model |
| `expo-image-picker` | "Take photo" and "Photo library" in the composer | Those two entries are hidden |
| `expo-document-picker` | "Choose file" in the composer | That entry is hidden |
| `react-native-safe-area-context` | Keeps the chat screen clear of the notch, status bar and home indicator | iOS falls back to React Native's `SafeAreaView` (with a deprecation warning); Android leaves no margin. Or pass `insets`. |

The attach button only shows when at least one picker is installed. Bare React Native apps can use these modules too once [`expo`](https://docs.expo.dev/bare/installing-expo-modules/) (Expo Modules) is installed.

### Platform permissions

Photo attachments need a usage description on iOS. With Expo, set it through the `expo-image-picker` config plugin:

```json title="app.json"
{
  "expo": {
    "plugins": [
      ["expo-image-picker", {
        "photosPermission": "Send photos to our support team",
        "cameraPermission": "Take photos to send to our support team"
      }]
    ]
  }
}
```

Bare React Native apps add `NSPhotoLibraryUsageDescription` and `NSCameraUsageDescription` to `ios/<App>/Info.plist`. Camera permission is requested on first use. Files can be up to 10 MB: images (the picker converts HEIC to JPEG), PDF, office documents, zip, mp4 and m4a.

On Android, keep `softwareKeyboardLayoutMode: "resize"` (Expo's default, equivalent to `android:windowSoftInputMode="adjustResize"`): the chat screen relies on the window shrinking when the keyboard opens. On iOS, `KeyboardAvoidingView` handles it.

Expo Go runs the SDK as long as you only use `expo-secure-store`, `expo-application`, `expo-device` and the pickers. Push notifications through Firebase need a [development build](https://docs.expo.dev/develop/development-builds/introduction/).

## Initialization {#initialization}

Call `Hatang.configure` once at start-up, outside any component (for example at the top of `App.tsx` or `index.js`). The SDK bootstraps the visitor, stores the token and opens the socket right away (`autoConnect: true`).

```ts title="hatang.ts"
import { Hatang } from "@hatang/chat-react-native";

Hatang.configure({
  siteId: "site_…",
  appKey: "hak_…",
  // baseUrl: "https://chat.hatang.dev", // default
  // locale: "en",
  // autoConnect: false,                 // call Hatang.connect() yourself later
});
```

| Option | Meaning |
|---|---|
| `siteId`, `appKey` | Required. From the website's **Apps** tab |
| `baseUrl` | The gateway. Default `https://chat.hatang.dev`; change it only for a local gateway (see [Troubleshooting](#troubleshooting)) |
| `locale` | `"vi"` / `"en"`. When unset: the stored choice, then the site, then the device |
| `storage`, `secureStorage` | Your own storage, see [Storage](#storage) |
| `autoConnect` | `false` to call `Hatang.connect()` yourself later (for example after a consent screen) |
| `openOnProactiveMessage` | Open the chat when the team starts a conversation (default `true`) |
| `commerce` | How "Buy" and "Pay" on cards behave, see [Sales cards and in-app purchase](#commerce) |
| `logger` | `{ debug?, info?, warn?, error? }` for logs while debugging |
| `app` | Overrides for the app details sent to the gateway (`version`, `os`, `osVersion`, `deviceModel`…) |

Calling `configure` again replaces the previous engine (listeners added with `Hatang.on` are kept). The facade also has `Hatang.connect()`, `Hatang.disconnect()`, `Hatang.dispose()`, `Hatang.isConfigured`, `Hatang.session` (`visitorId`, `conversationId`, `locale`, `isIdentified`, `email`, `name`), `Hatang.unreadCount`, `Hatang.availability` and `Hatang.connection`. `Hatang.engine` returns the full `ChatEngine` for advanced cases.

The SDK follows `AppState`: when the app goes to the background the gateway is told at once (`app.state`) so new replies go out as push notifications, and the socket closes about 10 seconds later. Back in the foreground, the SDK reconnects and catches up on missed messages.

### Storage {#storage}

By default the SDK uses `expo-secure-store` for both the visitor token and the small session settings. Keys are readable after the first device unlock, so a push that wakes the app in the background can still read the token. Without `expo-secure-store` and without `storage`, the SDK keeps everything in memory and the visitor is treated as new on every launch.

`storage` takes an AsyncStorage-like object (`getItem`, `setItem`, `removeItem`) or the SDK's `{ get, set, remove }` shape (every function returns a `Promise`):

```ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Keychain from "react-native-keychain";
import { Hatang, type HatangStorage } from "@hatang/chat-react-native";

// A token adapter built on react-native-keychain
const keychainStore: HatangStorage = {
  get: async (key) => {
    const c = await Keychain.getGenericPassword({ service: key });
    return c ? c.password : null;
  },
  set: async (key, value) => {
    await Keychain.setGenericPassword("hatang", value, { service: key });
  },
  remove: async (key) => {
    await Keychain.resetGenericPassword({ service: key });
  },
};

Hatang.configure({ siteId: "site_…", appKey: "hak_…", storage: AsyncStorage, secureStorage: keychainStore });
```

Order of choice: the visitor token goes to `secureStorage`, else `expo-secure-store`, else `storage`. So when `expo-secure-store` is installed, the token always lives there even if you pass `storage`. Avoid putting the token in AsyncStorage if you have another option: AsyncStorage is not encrypted.

## Identify visitors {#identify-visitors}

When a user signs in, call `identify` with their id in your system. `hash` is the HMAC-SHA256 (hex) of `userId` with the site's identity secret, **computed on your backend** and returned to the app. Never put the secret in the app. A signed identity is merged with earlier sessions of the same person (including on the web); an unsigned one is only stored as self-reported details. See [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md).

```ts
// Your backend returns { userId, hash } after sign-in
await Hatang.identify({
  userId: me.id,
  email: me.email,
  name: me.name,
  phone: me.phone,
  hash: me.hatangHash,
  data: { plan: "pro", orders: 12 },
});
```

One line on your backend computes `hash`:

```js title="Node"
const hash = require("node:crypto").createHmac("sha256", process.env.HATANG_IDENTITY_SECRET).update(userId).digest("hex");
```

```php title="PHP"
$hash = hash_hmac('sha256', $userId, getenv('HATANG_IDENTITY_SECRET'));
```

When the identity already belongs to another visitor (the user chatted on another device, for example), the gateway merges the sessions: the SDK receives a new token, reloads the conversation and emits `sessionChanged` with `reason: "identity_merged"`. You do not need to do anything else.

### Sign-out

```ts
await Hatang.reset();
```

`reset()` revokes the old token, clears local state, starts a new visitor and registers the push token again for that visitor, so the next user on the same device does not see the previous user's conversations. To stop push for this device entirely, call `Hatang.clearPushToken()` before `reset()`.

## Open and embed the chat {#open-and-embed}

### HatangProvider and the default modal

Wrap your app in `HatangProvider`. When you do not pass `onOpenRequest`, the provider opens `HatangChatScreen` in a full-screen modal when `Hatang.open()` is called, when the user taps a push notification, or when the team starts a conversation.

```tsx title="App.tsx"
import { Button } from "react-native";
import { Hatang, HatangProvider, useHatangUnread } from "@hatang/chat-react-native";

export default function App() {
  return (
    <HatangProvider config={{ siteId: "site_…", appKey: "hak_…" }}>
      <Home />
    </HatangProvider>
  );
}

function SupportButton() {
  const unread = useHatangUnread();
  return <Button title={unread ? `Support (${unread})` : "Support"} onPress={() => Hatang.open()} />;
}
```

`config` is optional if `Hatang.configure()` already ran; the provider only reconfigures when `siteId`, `appKey` or `baseUrl` change. The provider also takes `theme` and `strings` for the default modal. `Hatang.open(conversationId)` opens a specific earlier conversation, and `Hatang.close()` closes the chat.

### The chat screen in your navigation

To use your own route (React Navigation, Expo Router), pass `onOpenRequest` to the provider and render `HatangChatScreen` yourself:

```tsx
<HatangProvider onOpenRequest={({ conversationId }) => navigation.navigate("Support", { conversationId })}>
  {/* … */}
</HatangProvider>

// The "Support" route
<HatangChatScreen
  conversationId={route.params?.conversationId}
  onClose={() => navigation.goBack()}
  keyboardVerticalOffset={headerHeight}
/>
```

`onOpenRequest` receives `{ conversationId, source }`, where `source` is `"api"`, `"notification"` or `"proactive"`. On a cold start from a notification, the open request is held until the first provider listens.

| `HatangChatScreen` prop | Meaning |
|---|---|
| `conversationId` | Open this conversation (from the inbox or a notification); defaults to the active one |
| `onClose` | Adds a close button and handles the Android back button |
| `header` | `false` hides the built-in header, or pass a node to replace it |
| `keyboardVerticalOffset` | Your navigation header's height, for `KeyboardAvoidingView` |
| `insets` | Safe-area insets; read from `react-native-safe-area-context` by default. The top inset is ignored with `header={false}`; pass `{}` when your navigator already handles insets |
| `theme`, `strings` | See [Theming](#theming) and [Languages](#languages) |
| `commerce` | Purchase policy for this screen, overriding `Hatang.configure({ commerce })`; see [Sales cards and in-app purchase](#commerce) |
| `style` | Style of the outer container |

While the screen is visible, incoming messages count as read and the gateway sends no push for them.

### Inbox

`HatangInbox` lists the visitor's open and resolved conversations (who answered, last message, time, unread count). Pull down to refresh.

```tsx
<HatangInbox
  onOpen={(conversationId) => navigation.navigate("Support", { conversationId })}
  onNewConversation={() => navigation.navigate("Support")}
/>
```

`onNewConversation` shows a "New message" button. `HatangInbox` also takes `theme`, `strings` and `style`.

### Your own UI with hooks

Every hook reads the engine from the nearest `HatangProvider` (or from `Hatang` after `configure`):

| Hook | Returns |
|---|---|
| `useHatangUnread()` | Unread count for badges (0 before `configure`) |
| `useHatangAvailability()` | `{ online, within_hours, next_open_at, agents_online }` or `null` |
| `useHatangState()` | An immutable state snapshot (`items`, `pending`, `typingAgent`, `availability`, `unreadCount`, `connection`, `needsEmail`, `locale`…) |
| `useHatangChat()` | `state` plus actions: `send`, `retry`, `discard`, `attach`, `loadEarlier`, `rate`, `submitEmail`, `skipEmail`, `sendOfflineMessage`, `setThreadVisible`, `openConversation`… |
| `useHatangEvent(type, fn)` | Calls `fn` for one event type (or for every event with `useHatangEvent(fn)`) |
| `useHatangEngine()` | The `ChatEngine`, or `null` before `configure` |

```tsx
import { useHatangAvailability, useHatangChat, useHatangEvent } from "@hatang/chat-react-native";

function SupportTab() {
  const { state, send, loadEarlier, setThreadVisible } = useHatangChat();
  const availability = useHatangAvailability();
  useHatangEvent("messageReceived", (e) => console.log(e.message.content));
  // render state.items, call send(text)…
}
```

A custom UI should call `setThreadVisible(true)` while the conversation is on screen and `false` when it is hidden, so unread counts, read receipts and push decisions stay right. `HatangChatScreen` does this for you.

### Sales cards and in-app purchase {#commerce}

Agents send product cards, order cards, payment requests, quick replies and star ratings from the inbox; the SDK renders them under the message. By default **Buy** sends "I'd like to buy: …", and a payment card shows a VietQR code, the transfer note and a **Pay** button that opens the checkout page.

- **Physical goods and real-world services** (delivered orders, table bookings, appointments, in-person classes…): keep VietQR and the web checkout, the default. The App Store (Guideline 3.1.3(e)) and Google Play do not allow these through in-app purchase.
- **Digital goods and features used inside the app** (a Pro plan, coins, unlocking lessons…): the App Store (Guideline 3.1.1) and Google Play's payments policy require in-app purchase. Hand the purchase to your store integration through `onBuyProduct` with the store product id, and turn off `showExternalCheckout`:

```tsx
import { Platform } from "react-native";
import { Hatang, storeProductId } from "@hatang/chat-react-native";

Hatang.configure({
  siteId: "site_…",
  appKey: "hak_…",
  commerce: {
    showExternalCheckout: Platform.OS !== "ios", // iOS: no QR, no transfer note, no web checkout
    onBuyProduct: async (product) => {
      // The App Store / Google Play id set for each product in the dashboard
      const storeId = storeProductId(product, Platform.OS);
      if (!storeId) return false;       // not an in-app product: keep the default behavior
      await myIap.buy(storeId);         // for example react-native-iap or expo-iap
      return true;
    },
  },
});
```

Set the store product ids in the dashboard: **Products** → open a product → **Prices on the App Store and Google Play** (**App Store product ID** and **Google Play product ID**). Product cards carry both in `product.store_product_ids` (`app_store`, `google_play`), and `storeProductId(product, Platform.OS)` picks the right one for the platform.

> The whole flow, including store setup, reporting renewals and restores, importing existing subscribers and testing, is in [In-app purchases and subscriptions](https://hatang.dev/docs/guides/support/in-app-purchases.md).

**Subscriptions.** Inside your app the card shows the store price you set for that platform (App Store on iOS, Google Play on Android); subscription cards also carry `billing_period`, `trial_days`, and for Google Play `base_plan_id` and `offer_id`. Pass `Hatang.purchaseToken(userId)` as the app account token when buying (`request.apple.appAccountToken` / `request.google.obfuscatedAccountId` in react-native-iap 15+ / expo-iap 4+), then call `Hatang.reportPurchase({ storeProductId, transactionId, … })` after a successful purchase with the store's proof: `signedTransaction` (the StoreKit 2 JWS, `purchase.purchaseToken` on iOS in those libraries) on iOS, or `purchaseToken` and `productType: "subs"` (`"inapp"` for one-time products) on Android. Hatang checks it with Apple or Google before recording it on the customer, and acknowledges Google Play purchases. Connect the app under **Channels → your app → Subscriptions from the App Store and Google Play** so plans, renewals and refunds show on the customer's profile.

| `commerce` field | Meaning |
|---|---|
| `onBuyProduct(product)` | The **Buy** button. Return `true` (or a Promise of `true`) when you handled it; `false` keeps the default |
| `onPay(payment)` | The **Pay** button (`payment` is `card.payment`). Return `true` when handled; `false` opens the checkout page if `showExternalCheckout` allows it |
| `showExternalCheckout` | Default `true`. `false` hides the VietQR code, the transfer note and the web checkout |

A hook that throws counts as handled, so a failed in-app purchase never falls through to the web checkout. With `showExternalCheckout` off and no `onPay`, a payment card only shows the amount and a line saying the team will tell the customer how to complete the payment. The SDK depends on no store library; `<HatangChatScreen commerce={…} />` overrides the global policy for one screen.

## Events and data {#events-and-data}

```ts
const off = Hatang.on("conversion", (e) => analytics.track(e.kind, e.data)); // chat_started, lead, purchase
Hatang.on("messageReceived", (e) => showInAppBanner(e.message.content));
Hatang.on("protocolOutdated", () => promptUpdate());
Hatang.on("*", (e) => console.log(e.type));
off(); // unsubscribe
```

| Event | When |
|---|---|
| `ready` | Bootstrap and authentication finished (`visitorId`, `conversationId`) |
| `opened`, `closed` | The chat screen appears or goes away |
| `messageReceived`, `messageSent` | A message from the team or bot; a visitor message was stored (including from another device) |
| `conversationStatus` | `open`, `pending`, `resolved`, `closed` |
| `conversion` | `chat_started`, `lead`, `purchase` (with `value`, `currency`, `transaction_id`) |
| `sessionChanged` | `reason`: `identity_merged`, `reset`, `restored`, `token_rejected` |
| `protocolOutdated` | The gateway no longer serves this version; ask the user to update the app |

Listeners added with `Hatang.on` survive a new `configure` call. Inside components, use `useHatangEvent` so the listener is removed on unmount.

`Hatang.connection` is one of `"idle"`, `"connecting"`, `"connected"`, `"reconnecting"`, `"offline"`, `"outdated"` or `"revoked"`.

Send data to the inbox:

```ts
await Hatang.setData({ cart_total: 450000, last_order: "SO-1001" }); // shown to agents
Hatang.pushEvent("viewed_product", { sku: "TS-100" });             // the visitor's timeline
Hatang.setScreen("ProductPage", { sku: "TS-100" });                 // agents see where the visitor is
```

With React Navigation, call `Hatang.setScreen(route.name)` in the `NavigationContainer`'s `onStateChange`.

## Push notifications {#push-notifications}

Ha Tang sends push through **your own Firebase project** (FCM HTTP v1): upload the service account file on the website's **Apps** tab (see [Push notifications](https://hatang.dev/docs/guides/sdks/push-notifications.md)). The SDK does not depend on Firebase; your app uses `@react-native-firebase/messaging` and hands the token to the SDK. iOS uses Firebase Messaging too, because Ha Tang does not deliver through APNs directly yet.

```bash
npm install @react-native-firebase/app @react-native-firebase/messaging
# Expo: npx expo install @react-native-firebase/app @react-native-firebase/messaging
# then add "@react-native-firebase/app" to the plugins in app.json and create a development build
```

```ts title="push.ts"
import { PermissionsAndroid, Platform } from "react-native";
import messaging from "@react-native-firebase/messaging";
import { Hatang } from "@hatang/chat-react-native";

export async function wirePush() {
  if (Platform.OS === "ios") await messaging().requestPermission();
  if (Platform.OS === "android" && Platform.Version >= 33) {
    await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS);
  }

  // Register the device; safe before the SDK is ready, and sent again after the identity changes
  const token = await messaging().getToken();
  await Hatang.setPushToken(token, "fcm");
  messaging().onTokenRefresh((t) => Hatang.setPushToken(t, "fcm"));

  // App in the background, the user taps the notification
  messaging().onNotificationOpenedApp((m) => {
    if (Hatang.isHatangNotification(m.data)) Hatang.handleNotification(m.data);
  });

  // Cold start from a notification
  const initial = await messaging().getInitialNotification();
  if (initial && Hatang.isHatangNotification(initial.data)) Hatang.handleNotification(initial.data);
}
```

Call `wirePush()` after `Hatang.configure`. `isHatangNotification` checks `data.hatang == "1"`, so your own notifications and Ha Tang's can coexist. `handleNotification(data)` loads the conversation in `data.conversation_id` (waiting until the session is ready, or until `configure` on a cold start), then asks the UI to open it: the provider's modal, or your `onOpenRequest`. It returns `false` for payloads that are not Ha Tang's.

While the app is in the foreground and connected, the gateway does **not** send push (messages arrive over the socket), and it does not send push while the visitor is viewing the conversation. To show something for a foreground message, check `Hatang.isHatangNotification(remoteMessage.data)` in `messaging().onMessage`, or use the `messageReceived` event. Call `Hatang.clearPushToken()` on sign-out; `reset()` registers the token again for the new visitor.

Using `expo-notifications` instead of Firebase: get the native device token with `getDevicePushTokenAsync()` (not the Expo push token) and pass `"fcm"` on Android. On iOS that function returns an APNs token, so keep using Firebase Messaging there.

**Android 8+**: Ha Tang posts replies to the `hatang_messages` notification channel. Create a channel with this id (for example with `@notifee/react-native` or `expo-notifications`) to set its name and importance; otherwise Android shows the notification in Firebase's fallback channel.

**iOS**: besides the iOS app in Firebase, you need an App ID that matches the bundle ID exactly and an APNs key uploaded to Firebase (see [Push notifications → iOS apps](https://hatang.dev/docs/guides/sdks/push-notifications.md#ios-apps)). Then enable the **Push Notifications** and **Background Modes → Remote notifications** capabilities in Xcode (with Expo, set `ios.entitlements` / `UIBackgroundModes` in `app.json`; the `@react-native-firebase` config plugin does the rest).

## Deep links {#deep-links}

The "we replied" email sent to visitors contains a link `https://<your site>/c/<token>`. If your app handles universal links / app links for that domain (or a custom scheme with `?hatang_restore=<token>`), restore the session from the token and open the chat:

```ts
import { Linking } from "react-native";
import { Hatang } from "@hatang/chat-react-native";

async function onUrl(url: string | null) {
  const token = url ? Hatang.linkTokenFromUrl(url) : null;
  if (!token) return; // not a Hatang link
  await Hatang.restoreSession(token);
  Hatang.open();
}

Linking.getInitialURL().then(onUrl);
Linking.addEventListener("url", ({ url }) => onUrl(url));
```

`linkTokenFromUrl` accepts both `/c/<token>` and `?hatang_restore=<token>`. `restoreSession` stores the new session token, bootstraps again and emits `sessionChanged` with `reason: "restored"`. Restore tokens are single-use and expire, so do not store them.

## Theming {#theming}

By default the brand color and light/dark mode come from the site settings in the dashboard (`auto` follows the device's `useColorScheme()`). Override them with the `theme` prop of `HatangChatScreen`, `HatangInbox` or `HatangProvider`:

```tsx
<HatangChatScreen
  theme={{
    mode: "system", // "site" (default) | "system" | "light" | "dark"
    colors: { primary: "#E11D48", background: "#FFFFFF", surface: "#F8FAFC", text: "#0F172A", muted: "#475569" },
    darkColors: { background: "#0B1220" },
    radius: 12,
    fontFamily: "Inter-Regular",
  }}
/>
```

Colors you can override: `primary`, `onPrimary`, `background`, `surface`, `text`, `muted`, `bubbleVisitor`, `bubbleVisitorText`, `bubbleAgent`, `bubbleAgentText`, `danger`, `border`. `darkColors` applies in dark mode (it falls back to the default dark palette, not to `colors`, except for `primary`). Text on colored backgrounds (send button, the visitor's bubbles, links) is adjusted to keep at least 4.5:1 contrast, even with a light brand color, and the UI follows the system font size.

Every button has an `accessibilityLabel` and `accessibilityRole` and a touch target of at least 44×44 pt; new messages from the team are announced to screen readers; the typing indicator does not animate.

### Follow the widget settings in the dashboard {#widget-settings}

Some parts of `HatangChatScreen` come straight from the site's widget settings (**Support → Channels → your website**). Change them in the dashboard and the app picks them up the next time the chat opens, with no code change:

- **Header**: the subtitle under the title (in the current language) and the profile: photos of online agents, or the business logo (the first letter of the site name when there is no logo).
- **Greeting**: on or off, and whether it shows on phones. When the sender is set to an online agent, that agent's name shows under the greeting while the team is online. The teaser over the launcher exists only in the web widget.
- **Contact buttons**: Zalo, Messenger, phone and email appear as a row of buttons under the header and open in the matching app on the device. Override the "Call us" and "Email" labels with the `callUs` and `emailUs` strings; `header={false}` hides this row too.

With an older gateway that does not send these settings, the screen stays as before: the greeting always shows, the header shows agent photos, and there are no contact buttons.

## Languages {#languages}

The SDK ships Vietnamese and English. The language is chosen in this order: `locale` in `configure`, the visitor's stored choice, the site's default language, then the device language. Switch at runtime:

```ts
await Hatang.setLocale("en");
```

Override strings with the `strings` prop (it applies to the current language; strings you do not override keep their defaults). To override per language, pick by the state's `locale`:

```tsx
const { locale } = useHatangState();

<HatangChatScreen
  strings={locale === "en" ? { placeholder: "How can we help?", team: "Acme Support" } : { placeholder: "Bạn cần hỗ trợ gì?" }}
/>
```

`HATANG_STRINGS.vi` / `HATANG_STRINGS.en` hold every default string (type `HatangChatStrings`) if you need them in your own UI.

## Troubleshooting {#troubleshooting}

| Symptom | Cause and fix |
|---|---|
| `HatangError(invalid_app_key 401)` on start | `appKey` is wrong or belongs to another site. Check the **Apps** tab of the right website; keys start with `hak_`. |
| `HatangError(app_key_revoked 403)`, `Hatang.connection` is `"revoked"` | The key was revoked in the dashboard. The SDK stops retrying and the chat screen asks the user to update the app. Ship an update with a new key; `error.isAppKeyError` identifies this error in a custom UI. |
| `Hatang.connection` is `"outdated"`, `protocolOutdated` event | The gateway closed the socket with code 4004: the SDK is too old for the protocol. The SDK stops reconnecting; upgrade `@hatang/chat-react-native` and ship a release. |
| Warning `[hatang] No persistent storage` | `expo-secure-store` is not installed and no `storage` was passed: the visitor is forgotten on every launch. Install `expo-secure-store` or pass `storage` (see [Storage](#storage)). |
| Metro: `Unable to resolve module expo-…` | A custom Metro config turned off optional dependencies. Expo's default config and `@react-native/metro-config` allow them; if you configure Metro yourself, set `transformer.allowOptionalDependencies: true` in `metro.config.js`. |
| No attach button, or no "Choose file" entry | `expo-image-picker` / `expo-document-picker` is not installed, or it is installed but the native app was not rebuilt (a new native module needs a new build, not just a JS reload). |
| The keyboard covers the composer on Android | `softwareKeyboardLayoutMode` is not `"resize"` (or `windowSoftInputMode` is not `adjustResize`). Under a navigation header, pass `keyboardVerticalOffset`. |
| Android emulator reaches the local API but the socket does not connect | The gateway builds the WebSocket address from its own `CHAT_URL` (`localhost`), so `10.0.2.2` is not enough. Run `adb reverse tcp:3002 tcp:3002` and keep `baseUrl: "http://localhost:3002"`. For a real phone on Wi-Fi, set the gateway's `CHAT_URL` to your dev machine's LAN IP and use that same address as `baseUrl`. |
| iOS cannot call `http://` during development | App Transport Security blocks plain HTTP. Add an `NSAllowsLocalNetworking` exception for debug builds only; production always uses `https://chat.hatang.dev`. |
| No push notifications | Check the decision order on [Push notifications](https://hatang.dev/docs/guides/sdks/push-notifications.md): no push while the app is in the foreground or the visitor is viewing the conversation; the website's **Apps** tab must have a Firebase file; click **Send a test** to reach the most recent device. Firebase does not run in Expo Go: use a development build. |
| Tapping a notification does not open the chat | `handleNotification` only requests an open. You need a `HatangProvider` without `onOpenRequest` (the default modal) or an `onOpenRequest` that navigates to a route with `HatangChatScreen`. |
| `rate_limit_exceeded` | 5 messages per 10 seconds and 60 per minute per visitor; 30 bootstraps per minute per install. `error.isTransient` says it is worth retrying later. |

Pass `logger: console` to `configure` to see socket frames and HTTP calls in the console while debugging.

## Changelog {#changelog}

**0.4.1**: the npm page describes the SDK and Ha Tang and links this guide; the README ends with an About Ha Tang section. No code changes.

**0.4.0**: in-app purchases. `Hatang.purchaseToken(userId)` (and `purchaseToken(siteId, userId)`) gives the app account token for StoreKit / Google Play; `Hatang.reportPurchase({ ... })` after a purchase, with `signedTransaction` (iOS) or `purchaseToken` + `productType` (Android): Ha Tang verifies it with the store and shows the plan or purchase on the customer. Product cards carry `base_plan_id`, `offer_id`, `billing_period`, `trial_days`.

**0.3.1**: package metadata fix (`repository.url` in the `git+https://` form npm expects).

**0.3.0**: `HatangChatScreen` follows the site's widget settings (older gateways keep the previous behavior): the header subtitle, the business logo (or the site name's initial) instead of agent photos when the profile is "business", the greeting on/off and on-phones switches with the first online agent's name under it for the "online agent" sender, and Zalo, Messenger, phone and email contact buttons under the header, opened with `Linking` (hidden with `header={false}`). Adds the `callUs` and `emailUs` strings and re-exports the `ContactButton` type.

**0.2.0**: a payment card switches to "Paid" as soon as the timeline has an `order_paid` message or event for its order or payment; the VietQR code is drawn with plain Views from the EMVCo payload (`qr_payload`, or from the public checkout for older cards) because the gateway serves `qr_image_url` as SVG, with a retry button on failure; product, order and payment cards no longer repeat their plain-text summary in a bubble above the card, only the intro line (`metadata.intro`). Adds the commerce policy shared with the iOS, Android and Flutter SDKs: `HatangCommerce` (`onBuyProduct`, `onPay`, `showExternalCheckout`) through `Hatang.configure({ commerce })` or `<HatangChatScreen commerce={…} />`, and the helpers `storeProductId`, `paymentCardDisplay`, `runCommerceHook`, `paidMarks`, `isPaymentPaid` and `cardBubbleText`. No new peer or native dependency: the QR encoder is bundled.

**0.1.0**: first release. `Hatang.configure` with a site id and app key; `HatangProvider` with the default modal or `onOpenRequest`; `HatangChatScreen` and `HatangInbox`; the `useHatangChat`, `useHatangState`, `useHatangUnread`, `useHatangAvailability` and `useHatangEvent` hooks; the session API (`identify`, `setData`, `pushEvent`, `setScreen`, `setLocale`, `reset`, `restoreSession`, `linkTokenFromUrl`); push (`setPushToken`, `clearPushToken`, `isHatangNotification`, `handleNotification`); `app.state` from `AppState`; photo and file attachments through optional Expo modules; storage through `expo-secure-store` or your own adapter; safe-area insets; offline form and email prompt; Vietnamese and English; light and dark themes from the site color.

See also: [Compatibility](https://hatang.dev/docs/guides/sdks/compatibility.md) for the protocol version and minimum gateway, [Push notifications](https://hatang.dev/docs/guides/sdks/push-notifications.md), [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md) and the [Flutter SDK](https://hatang.dev/docs/guides/sdks/flutter.md).

Source: https://hatang.dev/docs/guides/sdks/react-native
