npm package: @parlr/react-native
Description: Official Parlr live chat SDK for React Native
Latest version: 0.4.0

<p align="center">
  <img src="https://parlr.chat/logo.svg" alt="Parlr" width="64" height="64" />
</p>

<h1 align="center">@parlr/react-native</h1>

<p align="center">
  <strong>The official Parlr live chat SDK for React Native.</strong><br />
  Add real-time customer messaging to your iOS &amp; Android app in minutes.
</p>

<p align="center">
  <a href="https://www.npmjs.com/package/@parlr/react-native"><img src="https://img.shields.io/npm/v/@parlr/react-native.svg?style=flat-square&color=6366f1" alt="npm version" /></a>
  <a href="https://www.npmjs.com/package/@parlr/react-native"><img src="https://img.shields.io/npm/dm/@parlr/react-native.svg?style=flat-square&color=6366f1" alt="npm downloads" /></a>
  <a href="https://www.npmjs.com/package/@parlr/react-native"><img src="https://img.shields.io/npm/l/@parlr/react-native.svg?style=flat-square" alt="license" /></a>
  <a href="https://parlr.chat/docs/sdk/react-native"><img src="https://img.shields.io/badge/docs-parlr.chat-6366f1?style=flat-square" alt="docs" /></a>
</p>

<p align="center">
  <a href="#quick-start">Quick Start</a> &bull;
  <a href="#components">Components</a> &bull;
  <a href="#hooks">Hooks</a> &bull;
  <a href="#user-identification">User Identification</a> &bull;
  <a href="#theming">Theming</a> &bull;
  <a href="#api-reference">API Reference</a> &bull;
  <a href="#changelog">Changelog</a>
</p>

---

## Features

| Feature | Description |
|---|---|
| **Real-time messaging** | WebSocket with automatic reconnection and exponential backoff |
| **Optimistic UI** | Messages appear instantly, sync in the background |
| **Typing indicators** | Show when agents are typing, send visitor typing events |
| **Read receipts** | Delivery status tracking (`sending` &rarr; `sent` &rarr; `read`) |
| **Pre-built UI components** | Drop-in `<ParlrChat>` and `<ParlrConversationList>` |
| **Pre-chat forms** | Collect visitor info (name, email, phone) before the first message |
| **CSAT surveys** | Satisfaction rating after conversation resolution |
| **Speech-to-text** | Voice dictation with recording banner, volume visualization, and accumulated transcription (iOS & Android) |
| **File attachments** | Images and documents via `expo-image-picker` / `expo-document-picker` |
| **Rich messages** | Cards, carousels, quick replies rendered natively |
| **Interactive chatbot** | Quick replies and postback buttons routed to chatbot engine |
| **Widget config** | Branding, greeting, placeholder, and team loaded from backend at boot |
| **Behavioral tracking** | Track screen views and custom events for automated triggers |
| **Knowledge base** | Self-service article search from the widget |
| **Offline queue** | Messages are queued and retried when connectivity returns |
| **Push notifications** | FCM (Android) and APNs (iOS) support |
| **Device metadata** | Platform, OS, screen size, locale, timezone &mdash; sent automatically |
| **User identification** | Flexible: `name`, `firstName`/`lastName`, `email`, `externalId` |
| **HMAC verification** | Prevent contact impersonation with server-side tokens |
| **Dark mode** | Automatic system detection + full theme customization |
| **TypeScript-first** | Complete type definitions for all APIs, hooks, and components |
| **Tiny footprint** | ~160 KB published size, 1 runtime dependency (`axios`) |

Works with **Expo** (managed & bare) and **bare React Native** projects.

---

## Installation

```bash
npm install @parlr/react-native
# or
yarn add @parlr/react-native
```

### Required peer dependency

```bash
npm install react-native-reanimated
```

### Optional dependencies

```bash
# Secure session persistence (recommended)
npm install expo-secure-store

# SVG icons (recommended — emoji fallback without it)
npm install react-native-svg

# File & image attachments
npm install expo-image-picker expo-document-picker

# Speech-to-text (voice dictation)
npm install expo-speech-recognition
```

> **Note:** Without `expo-secure-store`, sessions are stored in memory and won't persist across app restarts. Users will get a new session each time.

### Compatibility matrix

| Package | Minimum version | Required |
|---|---|---|
| `react` | 18.0.0 | Yes |
| `react-native` | 0.72.0 | Yes |
| `react-native-reanimated` | 3.0.0 | Yes |
| `expo-secure-store` | 13.0.0 | No |
| `expo-image-picker` | 15.0.0 | No |
| `react-native-svg` | 13.0.0 | No |
| `expo-document-picker` | 12.0.0 | No |
| `expo-speech-recognition` | 1.0.0 | No |

---

## Quick Start

### 1. Wrap your app with the provider

```tsx
import { ParlrProvider } from '@parlr/react-native';

export default function App() {
  return (
    <ParlrProvider workspaceId="your-workspace-id">
      <Navigation />
    </ParlrProvider>
  );
}
```

### 2. Add the chat screen

```tsx
import { ParlrChat } from '@parlr/react-native';

function SupportScreen({ navigation }) {
  return (
    <ParlrChat
      user={{
        email: 'alice@acme.com',
        firstName: 'Alice',
        lastName: 'Martin',
      }}
      onBack={() => navigation.goBack()}
    />
  );
}
```

### 3. Show unread count anywhere

```tsx
import { useParlr } from '@parlr/react-native';

function SupportButton() {
  const { unreadCount } = useParlr();

  return (
    <TouchableOpacity onPress={() => navigation.navigate('Support')}>
      <Text>Support</Text>
      {unreadCount > 0 && <Badge count={unreadCount} />}
    </TouchableOpacity>
  );
}
```

**That's it.** The SDK handles session management, WebSocket connections, message delivery, and UI rendering automatically.

---

## Components

### `<ParlrProvider>`

Root wrapper that initializes the SDK, manages the session, and opens the WebSocket connection. Place it near the top of your component tree (above any screen that uses Parlr hooks or components).

```tsx
<ParlrProvider
  workspaceId="your-workspace-id"
  locale="en"
  debug={__DEV__}
  theme={{ colors: { primary: '#E91E63' } }}
  onError={(err) => Sentry.captureException(err)}
>
  <App />
</ParlrProvider>
```

| Prop | Type | Default | Description |
|---|---|---|---|
| `workspaceId` | `string` | *required* | Your Parlr workspace ID (find it in **Settings > Channels > Mobile SDK**) |
| `apiBaseUrl` | `string` | `https://api.parlr.chat/api/v1/widget` | REST API base URL |
| `wsUrl` | `string` | `wss://ws.parlr.chat/widget-ws` | WebSocket endpoint |
| `locale` | `string` | `"fr"` | BCP-47 locale code &mdash; 5 built-in languages: `"fr"`, `"en"`, `"es"`, `"de"`, `"ar"` |
| `debug` | `boolean` | `false` | Enable verbose console logging (API calls, WS events, session lifecycle) |
| `theme` | `Partial<ParlrTheme>` | auto | Theme overrides merged with system light/dark theme (see [Theming](#theming)) |
| `identityToken` | `string` | &mdash; | HMAC token for secure identity verification (see [HMAC Verification](#identity-verification-hmac)) |
| `onError` | `(error: ParlrError) => void` | &mdash; | Global error callback for monitoring |

---

### `<ParlrChat>`

Full-featured chat screen. Handles session initialization, WebSocket, message sending/receiving, typing indicators, attachments, and CSAT surveys &mdash; all in one component.

```tsx
<ParlrChat
  user={{
    email: 'alice@acme.com',
    firstName: 'Alice',
    lastName: 'Martin',
    company: 'Acme Inc',
  }}
  onBack={() => navigation.goBack()}
  headerTitle="Help & Support"
  showPreChatForm={true}
  preChatFields={['name', 'email']}
  showSatisfactionSurvey={true}
/>
```

| Prop | Type | Default | Description |
|---|---|---|---|
| `user` | [`ParlrUser`](#parlruser) | &mdash; | Identify the user on mount (see [User Identification](#user-identification)) |
| `conversationId` | `string` | &mdash; | Resume an existing conversation (omit to start fresh) |
| `onBack` | `() => void` | &mdash; | Back button handler (shows back arrow when provided) |
| `headerTitle` | `string` | `"Support"` | Header bar title |
| `placeholder` | `string` | `widgetConfig.widgetPlaceholder` &rarr; `"Write a message..."` | Input placeholder text (falls back to backend widget config, then default) |
| `accentColor` | `string` | `widgetConfig.accentColor` &rarr; `widgetConfig.primaryColor` &rarr; `theme.colors.primary` | Accent color override (falls back through widget config chain) |
| `showHeader` | `boolean` | `true` | Show/hide the header bar |
| `showPreChatForm` | `boolean` | `false` | Collect user info before the first message |
| `preChatFields` | `Array<'name' \| 'email' \| 'phone'>` | `['name', 'email']` | Which fields to show in the pre-chat form |
| `showSatisfactionSurvey` | `boolean` | `true` | Show CSAT survey after conversation closes |
| `safeAreaBottom` | `number` | `0` | Bottom safe-area inset so the input bar sits above the keyboard |
| `allowSpeechToText` | `boolean` | `true` | Enable microphone button for voice dictation |
| `emptyStateTitle` | `string` | `widgetConfig.widgetGreeting` &rarr; auto | Custom title for the empty state (falls back to backend greeting, then default) |
| `emptyStateDescription` | `string` | auto | Custom description for the empty state |
| `onConversationClosed` | `() => void` | &mdash; | Callback fired when a conversation is closed |

**Built-in features:** online/offline status indicator, three-dot menu (close/reopen), optimistic message sending with WebSocket confirmation, agent typing animation, attachment picker with preview, speech-to-text dictation with recording banner and volume visualization, chatbot quick replies and postback buttons, message pagination (infinite scroll), auto-scroll to latest, closed conversation banner, CSAT star rating, system/bot message support, backend branding auto-applied.

---

### `<ParlrConversationList>`

Displays a list of conversations with last message preview, unread badges, assignee avatar, and status indicator.

```tsx
<ParlrConversationList
  onSelectConversation={(id) => navigation.navigate('Chat', { id })}
  onNewConversation={() => navigation.navigate('Chat')}
  statusFilter="open"
/>
```

| Prop | Type | Default | Description |
|---|---|---|---|
| `onSelectConversation` | `(id: string) => void` | *required* | Callback when tapping a conversation |
| `onNewConversation` | `() => void` | &mdash; | "New conversation" button handler |
| `headerTitle` | `string` | `"Conversations"` | Header title |
| `statusFilter` | `'open' \| 'closed' \| 'pending'` | &mdash; | Filter conversations by status |
| `showHeader` | `boolean` | `true` | Show/hide header |
| `accentColor` | `string` | `"#6366f1"` | Accent color |

---

### Lower-level components

These are used internally by `ParlrChat` but are exported for building custom UIs:

| Component | Description |
|---|---|
| `<ChatBubble message={msg} />` | Single message bubble (agent left, contact right) |
| `<TypingIndicator />` | Animated three-dot typing indicator |
| `<EmptyState />` | Welcome screen shown when no messages exist |
| `<PreChatForm onSubmit={fn} />` | Pre-chat information collection form |
| `<SatisfactionSurvey onSubmit={fn} />` | CSAT 1&ndash;5 star rating survey |
| `<AttachmentPicker onFilePicked={fn} />` | Photo / camera / document file picker |
| `<AttachmentPreview file={f} onSend={fn} />` | Attachment preview before sending |
| `<RichMessage content={rich} />` | Cards, carousels, quick replies |
| `<SpeechButton onTranscript={fn} />` | Microphone button with pulse animation, recording banner, and volume visualization |

---

## Hooks

### `useParlr()`

Access SDK state from any component inside `<ParlrProvider>`.

```tsx
const {
  isReady,              // true when SDK is initialized and session is active
  isConnected,          // true when WebSocket is connected
  session,              // Current session (token, contactId, workspaceId)
  conversations,        // All conversations for this contact
  unreadCount,          // Total unread messages across all conversations
  identify,             // Identify or update the current user
  refreshConversations, // Force-refresh the conversation list
  theme,                // Resolved theme (light/dark + user overrides)
  widgetConfig,         // Backend widget config (branding, greeting, team) — null until loaded
  trackEvent,           // Track a behavioral event for automated triggers
} = useParlr();
```

**Identifying a user:**

```tsx
await identify({
  email: 'alice@acme.com',
  firstName: 'Alice',
  lastName: 'Martin',
  company: 'Acme Inc',
  customAttributes: { plan: 'pro', mrr: 299 },
});
```

---

### `useChat(conversationId?)`

Manage a single conversation with real-time updates.

```tsx
const {
  messages,            // Message array (oldest first)
  isLoading,           // true during initial load
  hasError,            // true if an error occurred
  conversation,        // Active conversation metadata
  agentTyping,         // true when an agent is typing (auto-clears after 5s)
  sendMessage,         // Send a text message (appears instantly via optimistic UI)
  retryMessage,        // Retry a failed message by clientId
  notifyTyping,        // Send typing indicator to the server (debounced 3s)
  loadMore,            // Load older messages (pagination)
  hasMore,             // true if more pages are available
  closeConversation,   // Close the conversation
  reopenConversation,  // Reopen a closed conversation
} = useChat(conversationId);
```

**Sending messages:**

```tsx
// Appears instantly with status "sending", then updates to "sent" on server confirmation
await sendMessage('Hello, I need help with my order');

// Retry a failed message
const failed = messages.find(m => m.status === 'failed');
if (failed?.clientId) {
  await retryMessage(failed.clientId);
}
```

**How optimistic sending works:**

1. `sendMessage()` inserts the message locally with status `sending`
2. The message is sent via both REST (reliability) and WebSocket (speed) simultaneously
3. On REST success &rarr; status updates to `sent`, any WS echo with the same server ID is deduplicated. Additionally, the server may emit a `message_sent` WebSocket event for faster confirmation
4. On failure &rarr; status updates to `failed` (retryable up to 3 times with exponential backoff: 1s, 2s, 4s). The server may also emit `message_error` via WebSocket
5. Deduplication is performed by both `clientId` (optimistic key) **and** server `id`, preventing duplicates regardless of REST/WS arrival order
6. If no `conversationId` is provided, a new conversation is created automatically on the first `sendMessage()`

---

### `useSpeechRecognition(options?)`

Low-level hook for speech-to-text integration. Used internally by `<ParlrChat>` but exported for custom UIs.

```tsx
import { useSpeechRecognition } from '@parlr/react-native';

function MyCustomInput() {
  const [text, setText] = useState('');
  const {
    isRecording,       // true while the microphone is active
    isAvailable,       // true if expo-speech-recognition is installed
    transcript,        // Accumulated transcript (grows with each final segment)
    volumeLevel,       // 0–1 normalized volume level (updates in real-time)
    error,             // Last error message, if any
    startRecording,    // Start listening
    stopRecording,     // Stop listening
    toggleRecording,   // Toggle start/stop
    clearTranscript,   // Reset the accumulated transcript
  } = useSpeechRecognition({
    locale: 'en-US',
    onResult: (accumulated) => setText(accumulated),
    onStart: () => console.log('Speech started'),
    onStop: () => console.log('Speech stopped'),
    onError: (err) => console.warn(err),
  });

  return (
    <View>
      <Text>{isRecording ? `Recording (vol: ${volumeLevel})` : 'Idle'}</Text>
      <TextInput value={text} onChangeText={setText} />
      <Button title={isRecording ? 'Stop' : 'Speak'} onPress={toggleRecording} />
    </View>
  );
}
```

| Option | Type | Default | Description |
|---|---|---|---|
| `locale` | `string` | `"fr-FR"` | BCP-47 locale for speech recognition |
| `onResult` | `(transcript: string) => void` | &mdash; | Called with the accumulated transcript on each recognition result |
| `onStart` | `() => void` | &mdash; | Called when recognition starts |
| `onStop` | `() => void` | &mdash; | Called when recognition stops |
| `onError` | `(error: string) => void` | &mdash; | Called on recognition error |

**Key behavior:**
- Transcript is **accumulated** across `isFinal` segments &mdash; continuous dictation builds up text naturally
- Volume level updates in real-time for visual feedback (recording banner, VU meter)
- Listeners are registered once and cleaned up on unmount (no memory leaks)
- Optional haptic feedback via `expo-haptics` (if installed)

---

## User Identification

The SDK supports flexible user identification through the `ParlrUser` interface. You can identify users in two ways:

### Option 1: Using `name` (simple)

Pass a single `name` field. The SDK automatically splits it into `firstName` and `lastName` before sending to the server.

```tsx
<ParlrChat
  user={{
    email: 'alice.martin@acme.com',
    name: 'Alice Martin',        // Automatically split: firstName="Alice", lastName="Martin"
  }}
/>
```

**Splitting rules:**
- `"Alice Martin"` &rarr; `firstName: "Alice"`, `lastName: "Martin"`
- `"Alice"` &rarr; `firstName: "Alice"` (no lastName)
- `"Alice Marie Martin"` &rarr; `firstName: "Alice"`, `lastName: "Marie Martin"`
- `"  Alice  Martin  "` &rarr; `firstName: "Alice"`, `lastName: "Martin"` (trimmed)

### Option 2: Using `firstName` / `lastName` (explicit)

For precise control, pass `firstName` and `lastName` directly. These take priority over `name`.

```tsx
<ParlrChat
  user={{
    email: 'alice.martin@acme.com',
    firstName: 'Alice',
    lastName: 'Martin',
  }}
/>
```

> **Important:** If neither `name`, `firstName`, nor `lastName` are provided, the contact name in your Parlr dashboard will fall back to the email prefix (e.g., `alice.martin@acme.com` &rarr; `alice.martin`).

### Full `ParlrUser` interface

```typescript
interface ParlrUser {
  email?: string;                          // Contact email
  name?: string;                           // Full name (auto-split into firstName/lastName)
  firstName?: string;                      // First name (takes priority over name)
  lastName?: string;                       // Last name (takes priority over name)
  externalId?: string;                     // Your internal user ID
  phone?: string;                          // Phone number (E.164 format recommended)
  company?: string;                        // Company name
  customAttributes?: Record<string, unknown>; // Custom key-value pairs visible in the dashboard
}
```

### Custom attributes

Custom attributes appear in the **Contact Details** panel of your Parlr dashboard:

```tsx
await identify({
  email: 'alice@acme.com',
  firstName: 'Alice',
  lastName: 'Martin',
  company: 'Acme Inc',
  customAttributes: {
    plan: 'enterprise',
    mrr: 2990,
    signupDate: '2025-06-15',
    accountManager: 'Bob',
  },
});
```

---

## Device Metadata

The SDK automatically collects and sends device metadata when a session is created. No configuration needed.

| Data | Example | Dashboard panel |
|---|---|---|
| Platform | `iOS`, `Android` | Visitor Device |
| OS version | `18.2`, `35` (Android API) | Visitor Device |
| Screen resolution | `430x932` | Visitor Device |
| Locale | `fr-FR` | Location |
| Timezone | `Europe/Paris` | Location |
| SDK version | `ParlrSDK/1.0` | User-Agent |

This data appears in the **Visitor Device** and **Location** sections of each contact's profile in the Parlr dashboard.

---

## Theming

The SDK automatically detects your system's dark/light mode preference. You can override any theme value.

### Brand color only (most common)

```tsx
<ParlrProvider
  workspaceId="your-workspace-id"
  theme={{
    colors: {
      primary: '#E91E63',
      contactBubble: '#E91E63',
    },
  }}
>
```

### Full custom theme

```tsx
<ParlrProvider
  workspaceId="your-workspace-id"
  theme={{
    colors: {
      primary: '#FF6B00',
      primaryText: '#FFFFFF',
      background: '#1A1A2E',
      surface: '#16213E',
      text: '#EAEAEA',
      textSecondary: '#A0A0B0',
      border: '#2A2A4A',
      agentBubble: '#16213E',
      agentText: '#EAEAEA',
      contactBubble: '#FF6B00',
      contactText: '#FFFFFF',
    },
    borderRadius: { bubble: 16, input: 12, button: 8, avatar: 20 },
    spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
    typography: { headerSize: 18, bodySize: 15, captionSize: 12 },
  }}
>
```

### Programmatic theme access

```tsx
import { useParlr, defaultLightTheme, defaultDarkTheme, mergeTheme } from '@parlr/react-native';

// Access resolved theme in any component
const { theme } = useParlr();
<View style={{ backgroundColor: theme.colors.background }} />

// Build a theme from scratch
const custom = mergeTheme(defaultDarkTheme, { colors: { primary: '#FF6B00' } });
```

<details>
<summary><strong>Full ParlrTheme interface</strong></summary>

```typescript
interface ParlrTheme {
  colors: {
    primary: string;         // Accent / brand color
    primaryText: string;     // Text on primary color
    background: string;      // Screen background
    surface: string;         // Card / input background
    surfaceDark: string;     // Darker surface (avatars, headers)
    text: string;            // Primary text color
    textSecondary: string;   // Secondary text, timestamps
    border: string;          // Borders, dividers
    success: string;         // Online indicator, success states
    error: string;           // Error states, failed messages
    agentBubble: string;     // Agent message bubble background
    agentBubbleDark: string; // Reserved
    agentText: string;       // Agent message text color
    agentTextDark: string;   // Reserved
    contactBubble: string;   // Contact message bubble background
    contactText: string;     // Contact message text color
  };
  borderRadius: {
    bubble: number;   // Message bubbles (default: 16)
    input: number;    // Text input (default: 12)
    button: number;   // Buttons (default: 8)
    avatar: number;   // Avatars (default: 20)
  };
  spacing: {
    xs: number;  // 4
    sm: number;  // 8
    md: number;  // 16
    lg: number;  // 24
    xl: number;  // 32
  };
  typography: {
    headerSize: number;   // 18
    bodySize: number;     // 15
    captionSize: number;  // 12
  };
}
```

</details>

---

## Widget Configuration

The SDK automatically fetches your workspace's widget configuration (branding, greeting, team) at boot from the backend. No manual setup needed.

```tsx
const { widgetConfig } = useParlr();

// widgetConfig contains:
// - workspaceName, logoUrl
// - primaryColor, accentColor (auto-applied to theme)
// - widgetGreeting (used as EmptyState title)
// - widgetPlaceholder (used as input placeholder)
// - hideBranding
// - team: [{ displayName, avatarUrl, role }]
```

**Color resolution chain:** Your theme overrides always take priority. If you don't set `theme.colors.primary`, the SDK applies colors from the backend config in this order:

1. `accentColor` from widget config
2. `primaryColor` from widget config
3. SDK default theme color

---

## Error Handling

The SDK exports a typed error hierarchy for precise error handling:

```tsx
import {
  ParlrError,            // Base class for all SDK errors
  ParlrNetworkError,     // HTTP errors (includes statusCode property)
  ParlrAuthError,        // 401/403 — expired or invalid session
  ParlrValidationError,  // Malformed server response (includes field property)
  ParlrConnectionError,  // WebSocket errors (includes code, reason properties)
} from '@parlr/react-native';
```

**Example: global error monitoring**

```tsx
<ParlrProvider
  workspaceId="your-workspace-id"
  onError={(error) => {
    if (error instanceof ParlrAuthError) {
      // Session expired — the SDK auto-refreshes, no action needed
    } else if (error instanceof ParlrNetworkError) {
      console.warn(`HTTP error: ${error.statusCode}`);
    } else if (error instanceof ParlrConnectionError) {
      console.warn(`WebSocket closed: ${error.code} ${error.reason}`);
    }
    // Send to your error tracking service
    Sentry.captureException(error);
  }}
>
```

---

## Offline Support

Messages sent while offline are queued locally and automatically retried when connectivity returns.

```tsx
import {
  queueMessage,
  getQueuedMessages,
  removeFromQueue,
  flushQueue,
} from '@parlr/react-native';

// Manually queue a message
queueMessage({
  conversationId: 'conv_123',
  content: 'Hello',
  clientId: 'uuid-v4',
  timestamp: Date.now(),
});

// Inspect the queue
const queued = await getQueuedMessages();
console.log(`${queued.length} messages pending`);

// Flush (send all queued messages)
await flushQueue(api);
```

Uses `AsyncStorage` when available, falls back to in-memory storage.

---

## Push Notifications

Register device tokens for FCM (Android) or APNs (iOS) push notifications:

```tsx
import { registerPushToken, unregisterPushToken } from '@parlr/react-native';

// After obtaining the device token from expo-notifications or @react-native-firebase/messaging
await registerPushToken(api, deviceToken, 'ios');    // or 'android'

// On logout or when the user disables notifications
await unregisterPushToken(api, deviceToken);
```

---

## Behavioral Tracking

Track screen views and custom events for automated triggers configured in the Parlr dashboard.

```tsx
const { trackEvent } = useParlr();

// Track a custom event
trackEvent('PRODUCT_VIEWED', 'https://myapp.com/products/123', {
  productId: '123',
  price: 29.99,
});
```

The SDK automatically tracks a `SCREEN_VIEW` event when `<ParlrProvider>` mounts. Use `trackEvent()` to send custom events that match your dashboard trigger rules.

---

## Knowledge Base

Search published knowledge base articles from the widget:

```tsx
import { createApiClient } from '@parlr/react-native';

// Search articles
const articles = await api.searchKBArticles('password reset', 5);
// Returns: [{ id, title, excerpt }]
```

---

## Speech-to-Text

The SDK supports voice dictation via the device microphone, powered by `expo-speech-recognition`. When installed, a microphone button appears in the input bar. During recording, a visual banner shows:

- A pulsing indicator dot (uses `accentColor`)
- Localized "Speak now..." label (5 languages)
- A real-time volume bar
- The input placeholder changes to "Speak..." during recording
- The microphone icon switches to a crossed-out microphone

All recording UI colors are derived from `accentColor` / theme &mdash; no hardcoded colors.

### Installation

```bash
npm install expo-speech-recognition
```

**iOS** &mdash; add to your `Info.plist`:

```xml
<key>NSSpeechRecognitionUsageDescription</key>
<string>Used for voice dictation in the chat</string>
<key>NSMicrophoneUsageDescription</key>
<string>Used for voice dictation in the chat</string>
```

**Android** &mdash; add to your `AndroidManifest.xml`:

```xml
<uses-permission android:name="android.permission.RECORD_AUDIO" />
```

### Usage (integrated)

The microphone button is shown by default in `<ParlrChat>`. Disable it with:

```tsx
<ParlrChat allowSpeechToText={false} />
```

### Usage (standalone)

```tsx
import { SpeechButton, isSpeechAvailable } from '@parlr/react-native';

function MyCustomInput() {
  const [text, setText] = useState('');

  return (
    <View style={{ flexDirection: 'row' }}>
      <TextInput value={text} onChangeText={setText} />
      <SpeechButton
        onTranscript={setText}
        locale="en-US"
        accentColor="#6366f1"
        theme={theme}
      />
    </View>
  );
}
```

### Using the hook directly

For full control over the recording UX, use the `useSpeechRecognition` hook:

```tsx
import { useSpeechRecognition } from '@parlr/react-native';

const { isRecording, transcript, volumeLevel, toggleRecording } = useSpeechRecognition({
  locale: 'fr-FR',
  onResult: (text) => setInputValue(text),
});
```

See [`useSpeechRecognition()`](#usespeechrecognitionoptions) in the Hooks section for full API.

### Graceful degradation

When `expo-speech-recognition` is **not installed**, `<SpeechButton>` returns `null` and the microphone button is automatically hidden. No error, no crash &mdash; the input bar works exactly as before.

```tsx
import { isSpeechAvailable } from '@parlr/react-native';

if (isSpeechAvailable) {
  console.log('Speech-to-text is available');
}
```

---

## Identity Verification (HMAC)

For production apps, use HMAC verification to prevent contact impersonation.

**1. Generate the token server-side** using your workspace secret:

```javascript
// Node.js example
const crypto = require('crypto');
const hmac = crypto.createHmac('sha256', WORKSPACE_SECRET);
hmac.update(user.email);
const identityToken = hmac.digest('hex');
```

**2. Pass it to the provider:**

```tsx
<ParlrProvider
  workspaceId="your-workspace-id"
  identityToken={identityTokenFromYourBackend}
>
```

The token is sent as `X-Identity-Token` on every request. The Parlr server validates it against your workspace secret before accepting the identification.

---

## API Reference

### Types

<details>
<summary><strong>ParlrUser</strong></summary>

```typescript
interface ParlrUser {
  email?: string;                             // Contact email address
  name?: string;                              // Full name (auto-split into firstName/lastName)
  firstName?: string;                         // First name (takes priority over name)
  lastName?: string;                          // Last name (takes priority over name)
  externalId?: string;                        // Your internal user ID
  phone?: string;                             // Phone number
  company?: string;                           // Company name
  customAttributes?: Record<string, unknown>; // Custom key-value pairs
}
```

**Name resolution order:**
1. `firstName` / `lastName` if provided &rarr; used directly
2. `name` if provided &rarr; auto-split on whitespace (`"Alice Martin"` &rarr; `firstName: "Alice"`, `lastName: "Martin"`)
3. Neither &rarr; dashboard shows email prefix as fallback

</details>

<details>
<summary><strong>Session</strong></summary>

```typescript
interface Session {
  token: string;        // JWT session token
  contactId: string;    // Unique contact ID
  workspaceId: string;  // Workspace ID
  expiresAt: string;    // ISO 8601 expiration timestamp
}
```

</details>

<details>
<summary><strong>Conversation</strong></summary>

```typescript
interface Conversation {
  id: string;
  status: 'open' | 'resolved' | 'pending' | 'closed';
  subject: string | null;
  lastMessageAt: string | null;              // ISO 8601
  unreadCount: number;
  assignee?: {
    id: string;
    name: string;
    avatarUrl?: string;
  } | null;
}
```

</details>

<details>
<summary><strong>Message</strong></summary>

```typescript
interface Message {
  id: string;
  conversationId: string;
  senderType: 'contact' | 'agent' | 'system';
  senderName?: string;
  senderAvatarUrl?: string;
  content: string | null;                                   // Null for image/attachment-only messages
  createdAt: string;                                      // ISO 8601
  clientId?: string;                                      // For optimistic deduplication
  status?: 'sending' | 'sent' | 'failed' | 'read';
  attachments?: Attachment[];
  contentType?: 'text' | 'image' | 'file' | 'rich';
  metadata?: Record<string, unknown>;
}
```

</details>

<details>
<summary><strong>Attachment</strong></summary>

```typescript
interface Attachment {
  id: string;
  name: string;          // Original filename
  url: string;           // Download URL
  contentType: string;   // MIME type (e.g., "image/png")
  size: number;          // Size in bytes
}
```

</details>

<details>
<summary><strong>WidgetConfig</strong></summary>

```typescript
interface WidgetConfig {
  workspaceName: string;               // Workspace display name
  logoUrl: string | null;              // Workspace logo URL
  primaryColor: string | null;        // Primary brand color (auto-applied to theme)
  accentColor: string | null;         // Accent color (takes priority over primaryColor)
  widgetGreeting: string | null;      // Greeting shown in empty state
  widgetPlaceholder: string | null;   // Input field placeholder
  hideBranding: boolean;               // Whether to hide "Powered by Parlr"
  team: Array<{                        // Team members shown in the widget
    displayName: string;
    avatarUrl: string | null;
    role: string;
  }>;
}
```

Fetched automatically at boot from `GET /widget/config/{workspaceId}` (public endpoint, no session token required). Access via `useParlr().widgetConfig`.

</details>

<details>
<summary><strong>RichContent</strong></summary>

```typescript
interface RichContent {
  type: 'card' | 'carousel' | 'quick_replies' | 'buttons';
  card?: RichCard;
  carousel?: { cards: RichCard[] };
  quickReplies?: { text?: string; replies: Array<{ label: string; value: string }> };
  buttons?: RichAction[];
}

interface RichCard {
  title?: string;
  description?: string;
  imageUrl?: string;
  actions?: RichAction[];
}

interface RichAction {
  type: 'link' | 'postback' | 'reply';
  label: string;
  value: string;
}
```

</details>

<details>
<summary><strong>WebSocket events</strong></summary>

```typescript
interface WsEventMap {
  auth_ok: { contactId: string };
  auth_error: { code?: number; reason?: string; message?: string };
  new_message: WsNewMessagePayload;
  message_sent: { conversationId: string; messageId: string; idempotencyKey?: string };
  message_error: { conversationId: string; idempotencyKey?: string; code?: string; message?: string };
  typing_start: { conversationId: string; senderType?: 'agent' | 'contact'; senderName?: string };
  typing_stop: { conversationId: string; senderType?: 'agent' | 'contact'; senderName?: string };
  message_seen: { conversationId: string; messageId: string; seenBy: string };
  conversation_updated: { conversationId: string; status?: string; assignee?: object | null };
  sync_response: { conversationId: string; messages: WsNewMessagePayload[] };
  pong: {};
  disconnected: { code?: number; reason?: string };
  error: { code?: string; message?: string };
}
```

**Connection behavior:**
- Auto-reconnect with exponential backoff (1s &rarr; 30s max)
- Keep-alive ping every 25s, server timeout at 50s
- Automatic sync after reconnection &mdash; missed messages are fetched and deduplicated
- Gateway event normalization: `message.new` &rarr; `new_message`, `typing` &rarr; `typing_start`/`typing_stop`
- Outgoing event conversion: `send_message` &rarr; `message.send`, camelCase &rarr; snake_case payloads
- Clean listener teardown on component unmount

</details>

### All exports

```typescript
// Components
import {
  ParlrProvider,
  ParlrChat,
  ParlrConversationList,
  ChatBubble,
  TypingIndicator,
  EmptyState,
  PreChatForm,
  SatisfactionSurvey,
  AttachmentPicker,
  AttachmentPreview,
  RichMessage,
  SpeechButton,
} from '@parlr/react-native';

// Hooks
import { useParlr, useChat, useSpeechRecognition } from '@parlr/react-native';

// Errors
import {
  ParlrError,
  ParlrNetworkError,
  ParlrAuthError,
  ParlrValidationError,
  ParlrConnectionError,
} from '@parlr/react-native';

// Types
import type {
  ParlrUser,
  Session,
  Conversation,
  Message,
  Attachment,
  WidgetConfig,
  WsEventMap,
  WsNewMessagePayload,
  WsMessageSentPayload,
  WsMessageErrorPayload,
  WsSyncResponsePayload,
  ParlrTheme,
} from '@parlr/react-native';

// Theme utilities
import { defaultLightTheme, defaultDarkTheme, mergeTheme } from '@parlr/react-native';

// Offline queue
import { queueMessage, getQueuedMessages, removeFromQueue, flushQueue } from '@parlr/react-native';

// Push notifications
import { registerPushToken, unregisterPushToken } from '@parlr/react-native';

// Speech-to-text
import { isSpeechAvailable } from '@parlr/react-native';
```

---

## Examples

### Minimal integration

```tsx
import { ParlrProvider, ParlrChat } from '@parlr/react-native';

export default function App() {
  return (
    <ParlrProvider workspaceId="your-workspace-id">
      <ParlrChat />
    </ParlrProvider>
  );
}
```

### With React Navigation

```tsx
// App.tsx
import { ParlrProvider } from '@parlr/react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';

const Stack = createNativeStackNavigator();

export default function App() {
  return (
    <ParlrProvider workspaceId="your-workspace-id" locale="en">
      <NavigationContainer>
        <Stack.Navigator>
          <Stack.Screen name="Home" component={HomeScreen} />
          <Stack.Screen
            name="Support"
            component={SupportScreen}
            options={{ headerShown: false }}
          />
        </Stack.Navigator>
      </NavigationContainer>
    </ParlrProvider>
  );
}

// HomeScreen.tsx
import { useParlr } from '@parlr/react-native';

function HomeScreen({ navigation }) {
  const { unreadCount } = useParlr();

  return (
    <View>
      <TouchableOpacity onPress={() => navigation.navigate('Support')}>
        <Text>Contact Support {unreadCount > 0 ? `(${unreadCount})` : ''}</Text>
      </TouchableOpacity>
    </View>
  );
}

// SupportScreen.tsx
import { ParlrChat } from '@parlr/react-native';

function SupportScreen({ navigation }) {
  return (
    <ParlrChat
      user={{
        email: 'alice@acme.com',
        firstName: 'Alice',
        lastName: 'Martin',
      }}
      onBack={() => navigation.goBack()}
      headerTitle="Help & Support"
      showPreChatForm={true}
    />
  );
}
```

### With Expo Router

```tsx
// app/_layout.tsx
import { ParlrProvider } from '@parlr/react-native';
import { Stack } from 'expo-router';

export default function Layout() {
  return (
    <ParlrProvider workspaceId="your-workspace-id" locale="fr">
      <Stack />
    </ParlrProvider>
  );
}

// app/support.tsx
import { ParlrChat } from '@parlr/react-native';
import { useRouter } from 'expo-router';

export default function SupportScreen() {
  const router = useRouter();

  return (
    <ParlrChat
      user={{
        email: 'alice@acme.com',
        name: 'Alice Martin',
      }}
      onBack={() => router.back()}
    />
  );
}
```

### Conversation list + detail

```tsx
import { ParlrConversationList, ParlrChat } from '@parlr/react-native';

// List screen
function ConversationsScreen({ navigation }) {
  return (
    <ParlrConversationList
      onSelectConversation={(id) => navigation.navigate('Chat', { conversationId: id })}
      onNewConversation={() => navigation.navigate('Chat')}
    />
  );
}

// Chat screen
function ChatScreen({ route, navigation }) {
  return (
    <ParlrChat
      conversationId={route.params?.conversationId}
      onBack={() => navigation.goBack()}
    />
  );
}
```

### Custom chat UI with hooks

```tsx
import { useChat, ChatBubble, TypingIndicator } from '@parlr/react-native';
import { FlatList, TextInput, TouchableOpacity, View, Text } from 'react-native';
import { useState } from 'react';

function CustomChat({ conversationId }) {
  const [text, setText] = useState('');
  const {
    messages, agentTyping, sendMessage, notifyTyping, loadMore, hasMore,
  } = useChat(conversationId);

  const handleSend = async () => {
    if (!text.trim()) return;
    await sendMessage(text.trim());
    setText('');
  };

  return (
    <View style={{ flex: 1 }}>
      <FlatList
        data={messages}
        inverted
        keyExtractor={(m) => m.id}
        renderItem={({ item }) => <ChatBubble message={item} />}
        onEndReached={() => hasMore && loadMore()}
      />
      {agentTyping && <TypingIndicator />}
      <View style={{ flexDirection: 'row', padding: 8 }}>
        <TextInput
          value={text}
          onChangeText={(t) => { setText(t); notifyTyping(true); }}
          style={{ flex: 1, borderWidth: 1, borderRadius: 8, padding: 8 }}
          placeholder="Type a message..."
        />
        <TouchableOpacity onPress={handleSend} style={{ padding: 8 }}>
          <Text>Send</Text>
        </TouchableOpacity>
      </View>
    </View>
  );
}
```

---

## Advanced Configuration

### Self-hosted Parlr

Point the SDK to your own Parlr instance:

```tsx
<ParlrProvider
  workspaceId="your-workspace-id"
  apiBaseUrl="https://api.your-domain.com/api/v1/widget"
  wsUrl="wss://ws.your-domain.com/ws"
>
```

### Debug mode

```tsx
<ParlrProvider workspaceId="your-workspace-id" debug={true}>
```

Logs to the console:
- REST API calls and responses
- WebSocket events (connect, disconnect, messages)
- Session lifecycle (create, refresh, expire)
- Retry attempts with backoff timing

---

## FAQ

<details>
<summary><strong>How do I get my workspace ID?</strong></summary>

Sign up at [app.parlr.chat](https://app.parlr.chat), create a workspace, and find your workspace ID in **Settings > Channels > Mobile SDK**.

</details>

<details>
<summary><strong>Does the SDK work with Expo?</strong></summary>

Yes. The SDK works with both Expo managed workflow and bare React Native. All optional dependencies (`expo-secure-store`, `expo-image-picker`, `expo-document-picker`) are standard Expo packages.

</details>

<details>
<summary><strong>What happens when the device goes offline?</strong></summary>

Messages are queued locally and automatically retried when connectivity returns. The WebSocket reconnects with exponential backoff (1s up to 30s). The UI continues to work normally &mdash; messages show with `sending` status until confirmed.

</details>

<details>
<summary><strong>Can I use the SDK without any UI components?</strong></summary>

Yes. Use `useParlr()` and `useChat()` hooks to build a completely custom UI. The SDK handles all networking, state management, and real-time events for you.

</details>

<details>
<summary><strong>How does identity verification work?</strong></summary>

Generate an HMAC-SHA256 token server-side using your workspace secret and the user's email. Pass it as `identityToken` to `<ParlrProvider>`. The server validates the token before accepting the identification &mdash; this prevents visitors from impersonating other contacts.

</details>

<details>
<summary><strong>Why does my dashboard show the email prefix instead of the full name?</strong></summary>

Make sure you're passing `firstName`/`lastName` (or `name`) in the `user` prop. If only `email` is provided, the dashboard falls back to displaying the email prefix (e.g., `alice` from `alice@acme.com`).

</details>

---

## Changelog

### 0.3.5

- **Fix: duplicate messages (WS/REST race condition)** &mdash; the server does not include `clientId` in the WebSocket `new_message` echo, which prevented optimistic UI deduplication. Deduplication is now performed by both `clientId` **and** server `id` in `doSend` and the `message_sent` handler, eliminating the React error `Encountered two children with the same key` regardless of WS/REST arrival order.

### 0.3.4

- **Fix: message deduplication by server `id`** &mdash; first pass at fixing deduplication that relied on `clientId` only.

### 0.3.3

- **Fix: Reanimated crash on invalid color** &mdash; `accentColor` in `ParlrChat` used `??` instead of `||`, allowing empty strings `""` from the backend as color values, which crashed Reanimated. Fixed to use `||` which filters falsy values (null, undefined, `""`).

### 0.3.2

- **Fix: SecureStore crash on invalid key** &mdash; the session key `@parlr/session/{workspaceId}` contained `@` and `/` characters forbidden by `expo-secure-store` (which only accepts `a-z`, `0-9`, `.`, `-`, `_`), causing a crash at bootstrap on iOS/Android. Key renamed to `parlr_session_{workspaceId}`.
- **Note:** existing sessions saved under the old key will be ignored. A new session is created automatically on next launch (transparent to the user).

### 0.3.1

- **Missing type exports** &mdash; `WsMessageSentPayload`, `WsMessageErrorPayload`, `WsSyncResponsePayload`, `WidgetConfig` exported from `index.ts`.
- **Fix: `useSpeechRecognition` docs** &mdash; corrected `onEnd` &rarr; `onStop` (matches actual code), added missing `onStart` callback.
- **Fix: theme default values** &mdash; corrected examples (`borderRadius.bubble: 16`, `typography.headerSize: 18`, `bodySize: 15`).
- **Complete README for npm** &mdash; new sections: Widget Configuration, Behavioral Tracking, Knowledge Base. `WidgetConfig` interface documented. `WsEventMap` with `message_sent`, `message_error`, `sync_response`. Updated All exports section with all types.

### 0.3.0

- **Full WebSocket mapping** &mdash; Rust Gateway event normalization (`message.new` &rarr; `new_message`, `conversation.updated`, `message.sent`, `message.error`, `sync.response`) and outgoing event conversion (`send_message` &rarr; `message.send`, `typing_start` &rarr; `typing.start`). Automatic camelCase &harr; snake_case payload conversion.
- **Unified `typing` event** &mdash; automatic split into `typing_start`/`typing_stop` based on `is_typing` (compatible with both Java Handler and Rust Gateway).
- **Optimistic UI via WebSocket** &mdash; listens to `message_sent` for optimistic message confirmation (faster than REST), listens to `message_error` to mark messages as `failed`.
- **Sync after reconnection** &mdash; automatic `sync` request after WebSocket reconnection to fetch missed messages, with deduplication in `useChat`.
- **Widget config at boot** &mdash; widget config fetched (`GET /config/{workspaceId}`) in parallel with session bootstrap (public endpoint, no auth). Automatic `primaryColor`/`accentColor` application to theme via `mergeTheme` (integrator overrides always take priority).
- **Chatbot postback/reply** &mdash; `handleRichAction` wired on `ChatBubble` to send quick reply and postback button values as text messages (intercepted server-side by the chatbot).
- **Behavioral tracking** &mdash; `trackBehavioralEvent()` to `POST /workspaces/{id}/behavioral-events`, automatic `SCREEN_VIEW` tracking on mount, `trackEvent()` exposed via `useParlr()`.
- **Knowledge base self-service** &mdash; `searchKBArticles(query, limit)` to `GET /kb/articles/search`.
- **Attachment URL normalization** &mdash; relative attachment paths are prefixed with `apiBaseUrl`.
- **Removed `searchMessages()`** &mdash; dead backend endpoint, method removed from interface and implementation.
- **Speech-to-text overhaul** &mdash; new `useSpeechRecognition` hook: accumulated transcript, listeners registered once (no memory leaks), real-time volume, optional haptic feedback via `expo-haptics`.
- **Recording UX** &mdash; animated banner with pulsing dot, localized "Speak now..." label, real-time volume bar. All colors derived from `accentColor` / theme.
- **System message support (bot/AI)** &mdash; `senderType: 'system'` displayed on the agent side with "Bot" label.
- **i18n: 5 languages** &mdash; French, English, Spanish, German, Arabic. Full translations for all components.

### 0.2.2

- **Fix: text not cleared after sending dictated message** &mdash; when the user dictated a message and pressed send, the text reappeared in the input field because speech recognition continued in the background (`continuous: true`) and the `onTranscript` callback re-filled the field. The send button now stops dictation before sending.
- **Refactor: centralized logger with debug mode** &mdash; removed all hardcoded `console.log/warn/error`. The SDK no longer emits any logs in production. To enable diagnostic logs, pass `debug={true}` to `<ParlrProvider>`.

### 0.2.1

- **Fix: infinite WebSocket reconnection** &mdash; the Rust gateway sends events with dot notation (`auth.error`, `auth.success`) while the SDK expected underscores (`auth_error`, `auth_ok`). Event types are now normalized automatically. Fixes the bug where the SDK never detected WebSocket authentication success or failure.
- **Fix: stop reconnecting on auth error** &mdash; when the gateway rejects the token (`NO_TOKEN`, expired session), the SDK no longer loops indefinitely. Reconnection is suspended until a new `connect()` call with a fresh token (handled automatically by the Provider's session refresh).
- **Fix: close code 1008** &mdash; WebSocket close code `1008` (Policy Violation), used by the gateway for auth rejections, is now treated as an authentication error alongside `4001`/`4003`.
- **Fix: silent speech recognition failure** &mdash; `stopListening()` could silently crash on stale native subscriptions, blocking the entire `startListening()` chain without any error log. Added defensive try/catch on each `.remove()` and on the `stop()` call.
- **Fix: UI stuck in recording mode** &mdash; the Promise returned by `startListening()` was not caught in the microphone button handler. On rejection, the UI remained frozen in recording state. Added `.catch()` to reset button state.
- **Fix: speech recognition too short** &mdash; `continuous: false` mode cut listening at the first micro-pause (before the user finished speaking), causing an immediate `End event` or `no-speech` error. Switched to `continuous: true` so listening continues until the stop button is pressed.

### 0.2.0

- **Fix: WebSocket authentication** &mdash; the session token is now sent as a query parameter (`?token=...`) during WebSocket connection, instead of a post-connection authentication message. Fixes immediate disconnections on gateways that require authentication at handshake.
- **Fix: Speech-to-text locale** &mdash; automatic BCP-47 locale normalization for iOS and Android (e.g., `fr` becomes `fr-FR`). Fixes silent speech recognition when a short locale was used.

### 0.1.9

- **Fix: WebSocket endpoint** &mdash; default WebSocket URL changed from `/ws` (STOMP) to `/widget-ws` (raw JSON protocol expected by the SDK). Fixes 400/502 WebSocket connection errors.
- **Diagnostic: Speech-to-text logging** &mdash; added detailed diagnostic logs in the speech module to trace `expo-speech-recognition` loading, permissions, and recognition start/stop. Easier on-device debugging.

### 0.1.8

- **Fix: Speech-to-text permissions** &mdash; `startListening()` now calls `requestPermissionsAsync()` before starting recognition, triggering the microphone/speech permission popup on iOS and Android. Fixes the silent crash when permissions had not been granted.
- **Fix: Module destructuring** &mdash; `ExpoSpeechRecognitionModule` is now extracted directly at `require()` instead of being destructured on each call, preventing reference errors.

### 0.1.7

- **Fix: Speech-to-text API** &mdash; corrected `expo-speech-recognition` integration to use `ExpoSpeechRecognitionModule.addListener()` instead of the non-existent `addSpeechRecognitionListener` export. This fixes the `addSpeechRecognitionListener is not a function` crash when pressing the microphone button.

### 0.1.6

- **Speech-to-text** &mdash; voice dictation via `expo-speech-recognition` (optional peer dependency). A microphone button with pulse animation appears in the input bar. Streaming interim results are displayed in real-time.
- New `<SpeechButton>` component for standalone usage.
- New `isSpeechAvailable` utility to check availability at runtime.
- New `allowSpeechToText` prop on `<ParlrChat>` (default: `true`).
- New `MicrophoneIcon` SVG icon with emoji fallback.

### 0.1.5

- **Fix: ChatBubble crash on image messages** &mdash; fixed `Cannot read property 'length' of null` when a message contains only an image attachment (no text). The `Message.content` type is corrected from `string` to `string | null` to reflect the actual API contract. `ChatBubble` now handles `null` content gracefully.

### 0.1.4

- **SVG icons** &mdash; all UI icons now use inline SVGs via `react-native-svg` (optional) instead of emoji characters. Falls back to emoji gracefully if `react-native-svg` is not installed.

### 0.1.3

- **Name auto-split** &mdash; when passing `name` (e.g., `"Alice Martin"`) without `firstName`/`lastName`, the SDK now automatically splits it into `firstName` and `lastName` before sending to the server. This fixes an issue where the Parlr dashboard would show only the email prefix instead of the full name.

### 0.1.2

- Documentation improvements and updated metadata.

### 0.1.1

- **Device metadata** &mdash; the SDK now automatically collects and sends device information (platform, OS version, screen resolution, locale, timezone) when creating a session. This data appears in the **Visitor Device** and **Location** panels of your Parlr dashboard.
- Internal improvements to session creation payload.

### 0.1.0

- Initial release with full chat UI, WebSocket real-time messaging, theming system, hooks API, offline queue, push notifications, pre-chat forms, CSAT surveys, file attachments, rich messages, and TypeScript support.

---

## Support

- **Documentation:** [parlr.chat/docs/sdk/react-native](https://parlr.chat/docs/sdk/react-native)
- **Issues & feature requests:** [support@parlr.chat](mailto:support@parlr.chat)

---

## License

MIT &copy; [Parlr](https://parlr.chat)