{"_id":"@ahmadqarshi/react-native-swake","_rev":"2-04fce75fbe887479d306195b3c6f24ba","name":"@ahmadqarshi/react-native-swake","dist-tags":{"latest":"1.0.0"},"versions":{"1.0.0":{"name":"@ahmadqarshi/react-native-swake","version":"1.0.0","keywords":["react-native","swake","feedback","survey","nps","in-app-feedback","bug-report","expo"],"license":"MIT","_id":"@ahmadqarshi/react-native-swake@1.0.0","maintainers":[{"name":"swake","email":"ahmad.qarshi@outlook.com"},{"name":"hamza87","email":"hamza.207029@gmail.com"}],"homepage":"https://github.com/boltechsolutions/echopost/tree/master/packages/sdk-react-native#readme","bugs":{"url":"https://github.com/boltechsolutions/echopost/issues"},"dist":{"shasum":"c33719bab00f145f7ad863f0a74b3d5f7fc7a8f3","tarball":"https://registry.npmjs.org/@ahmadqarshi/react-native-swake/-/react-native-swake-1.0.0.tgz","fileCount":423,"integrity":"sha512-/ctYQvkiwdItEp8niAbTPhS4HYavLez5PGOxXsmRDPM4Yu44ZfLWB/3RdopiHwHU0nzb9JcEWBx36bn+k0Vdkw==","signatures":[{"sig":"MEUCIQDYFJ8dAn6NGfuCoQroPQVRbGbjWI0GJXl6BHyQR2eiFgIgIvDX5Ca0/RVBgju/Ohcm0nBGTQWU8Bgegbok2/N6MzA=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":1308077},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.js","react-native":"./dist/index.js"}},"gitHead":"831decf1961bb30f1390c3f1218f4db1db4bba38","scripts":{"dev":"tsc --watch","lint":"eslint src/","build":"tsc","prepack":"tsc","typecheck":"tsc --noEmit"},"_npmUser":{"name":"hamza87","email":"hamza.207029@gmail.com"},"repository":{"url":"git+https://github.com/boltechsolutions/echopost.git","type":"git","directory":"packages/sdk-react-native"},"_npmVersion":"11.6.2","description":"React Native SDK for Swake — feedback infrastructure for mobile apps","directories":{},"_nodeVersion":"24.11.1","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"typescript":"*","@types/react":"^18.0.0","react-native":">=0.73.0","@swake/shared":"workspace:*","react-native-svg":">=13.0.0","expo-image-picker":"^55.0.16","lucide-react-native":">=0.300.0","react-native-safe-area-context":"^5.8.0"},"peerDependencies":{"react":">=18.0.0","expo-sensors":">=12.0.0","react-native":">=0.73.0","react-native-svg":">=13.0.0","expo-image-picker":">=15.0.0","react-native-shake":">=3.0.0","lucide-react-native":">=0.300.0","react-native-webview":">=13.0.0","react-native-view-shot":">=3.8.0","react-native-safe-area-context":">=4.0.0","@react-native-async-storage/async-storage":">=1.18.0"},"peerDependenciesMeta":{"expo-sensors":{"optional":true},"expo-constants":{"optional":true},"expo-image-picker":{"optional":true},"react-native-shake":{"optional":true},"react-native-webview":{"optional":true},"react-native-view-shot":{"optional":true},"@react-native-community/netinfo":{"optional":true},"@react-native-async-storage/async-storage":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/react-native-swake_1.0.0_1786705286349_0.24304117268790182","host":"s3://npm-registry-packages-npm-production"}}},"time":{"created":"2026-08-14T11:01:26.103Z","modified":"2026-08-14T12:16:11.301Z","1.0.0":"2026-08-14T11:01:26.506Z"},"bugs":{"url":"https://github.com/boltechsolutions/echopost/issues"},"license":"MIT","homepage":"https://github.com/boltechsolutions/echopost/tree/master/packages/sdk-react-native#readme","keywords":["react-native","swake","feedback","survey","nps","in-app-feedback","bug-report","expo"],"repository":{"url":"git+https://github.com/boltechsolutions/echopost.git","type":"git","directory":"packages/sdk-react-native"},"description":"React Native SDK for Swake — feedback infrastructure for mobile apps","maintainers":[{"email":"ahmad.qarshi@outlook.com","name":"ahmadqarshi-bts"}],"readme":"# @ahmadqarshi/react-native-swake\n\nThe official React Native SDK for [Swake](https://swake.dev) — feedback infrastructure for mobile apps. Lets users report bugs, send feature requests, ask questions, and vote on a public product roadmap directly from your app, with screenshot annotation, shake-to-report, breadcrumb trails, session replay, a feedback history widget, a customizable trigger button, an offline-first queue, in-app notification support, push token registration, and a native in-app voting board.\n\n---\n\n## Contents\n\n- [Requirements](#requirements)\n- [Installation](#installation)\n- [Using Claude Code](#using-claude-code)\n- [Optional dependencies](#optional-dependencies)\n- [Setup](#setup)\n- [Identifying users](#identifying-users)\n- [Submitting feedback](#submitting-feedback)\n- [UI components](#ui-components)\n  - [SwakeProvider](#swakeprovider)\n  - [Feedback form modal](#feedback-form-modal)\n  - [Embedded feedback form](#embedded-feedback-form)\n  - [Bug reporter with screenshot annotation](#bug-reporter-with-screenshot-annotation)\n  - [Shake-to-report](#shake-to-report)\n  - [Submission list & detail screens](#submission-list--detail-screens)\n- [Voting boards](#voting-boards)\n  - [Native voting board](#native-voting-board)\n  - [Programmatic voting API](#programmatic-voting-api)\n  - [Custom board UI](#custom-board-ui)\n- [Surveys, NPS & polls](#surveys-nps--polls)\n  - [Automatic eligibility check](#automatic-eligibility-check)\n  - [Survey configuration](#survey-configuration)\n  - [Presentation modes](#presentation-modes)\n  - [Question types](#question-types)\n  - [Inline poll widget](#inline-poll-widget)\n  - [Survey callbacks](#survey-callbacks)\n  - [Manual survey methods](#manual-survey-methods)\n- [Notifications](#notifications)\n  - [In-app notifications](#in-app-notifications)\n  - [Push token registration](#push-token-registration)\n- [Ad serving](#ad-serving)\n  - [AdRenderer component](#adrenderer-component)\n  - [Impression tracking details](#impression-tracking-details)\n  - [Programmatic ad fetch](#programmatic-ad-fetch)\n  - [Cache management](#cache-management)\n  - [Supported content blocks](#supported-content-blocks)\n  - [Ad style properties](#ad-style-properties)\n- [Breadcrumb trails](#breadcrumb-trails)\n  - [Auto-capture](#auto-capture)\n  - [Navigation breadcrumbs](#navigation-breadcrumbs)\n  - [Manual breadcrumbs](#manual-breadcrumbs)\n  - [Breadcrumbs in bug reports](#breadcrumbs-in-bug-reports)\n- [Session replay](#session-replay)\n  - [How it works](#how-it-works)\n  - [Quick start](#quick-start)\n  - [Privacy controls](#privacy-controls)\n  - [Replay configuration options](#replay-configuration-options)\n  - [Manual replay control](#manual-replay-control)\n  - [Event schema](#event-schema)\n  - [Performance budget](#performance-budget)\n  - [Plan limits (replay)](#plan-limits-replay)\n- [Feedback history widget](#feedback-history-widget)\n- [Customizable trigger button](#customizable-trigger-button)\n  - [Trigger position](#trigger-position)\n  - [Trigger style](#trigger-style)\n  - [Built-in icons](#built-in-icons)\n  - [Extended FAB with label](#extended-fab-with-label)\n  - [Hidden trigger & programmatic control](#hidden-trigger--programmatic-control)\n- [Plan limits](#plan-limits)\n- [Offline queue](#offline-queue)\n- [Theme customisation](#theme-customisation)\n- [API reference](#api-reference)\n- [TypeScript types](#typescript-types)\n- [Building from source](#building-from-source)\n- [Local testing with yalc](#local-testing-with-yalc)\n- [Troubleshooting](#troubleshooting)\n\n---\n\n## Requirements\n\n| Requirement | Version |\n|---|---|\n| React Native | `>= 0.73` |\n| React | `>= 18` |\n| TypeScript | `>= 5` (recommended) |\n\n---\n\n## Installation\n\n```bash\n# npm\nnpm install @ahmadqarshi/react-native-swake lucide-react-native react-native-svg\n\n# yarn\nyarn add @ahmadqarshi/react-native-swake lucide-react-native react-native-svg\n\n# pnpm\npnpm add @ahmadqarshi/react-native-swake lucide-react-native react-native-svg\n```\n\nThe SDK renders its UI icons with [Lucide](https://lucide.dev), so **`lucide-react-native`** (and its own dependency **`react-native-svg`**) are required peer dependencies — install them alongside the SDK. Everything else below is optional and unlocks additional features.\n\n---\n\n## Using Claude Code\n\nPrefer to let an AI coding agent do the wiring? Run [Claude Code](https://claude.com/claude-code) from the root of your React Native project and paste the prompt below. It reads these docs as the source of truth, detects your stack, asks you for your API key and which peers to install, then wires up the provider, `init()`, `identify()`, breadcrumbs, and the native setup — in one shot.\n\n### Prerequisites\n\nBefore you run the prompt, set up your project in the Swake portal and grab the values the agent will ask for — it only writes placeholders to your env files, so you supply the real values yourself.\n\n1. Sign in to the [Swake portal](https://swake.io) and create a project (or open an existing one).\n2. In **Project settings → API keys**, create a key and copy it — it's shown **once** and starts with `ep_live_`.\n3. If you want the in-app [voting board](#voting-boards), create a board and copy its **slug** from the board's URL / settings.\n\nHave these ready when the agent prompts you — they're the same values you'd pass to `Swake.init()` in [Setup](#setup):\n\n| Value | Where to find it | Example |\n|---|---|---|\n| `apiKey` | Project settings → API keys | `ep_live_…` |\n| `baseUrl` | API endpoint (optional — default if omitted) | `https://api.swake.dev` |\n| Board slug | Voting board settings (optional — for the in-app board) | `public-roadmap` |\n\n> The in-app voting board is fully native and needs no `react-native-webview` — it's rendered from the public board API using the identified user's identity.\n\n> **The prompt is additive and safe:** a Swake or network error must never break your app's boot or existing behavior. The agent will pause and ask before installing packages or writing secrets, and it never guesses APIs that aren't in these docs.\n\n### The prompt\n\nRun Claude Code from your project root and paste this:\n\n```text\nFully integrate the Swake SDK (feedback infra: forms, bug reports, surveys, notifications, voting boards). Additive: a Swake/network error must NEVER break boot or existing behavior. Never guess or fake APIs.\n\n1. FIRST read the docs (source of truth for package, peers, init/identify, provider, native setup). Fetch with a browser User-Agent (a plain fetch returns 403), e.g. curl -sL -A \"Mozilla/5.0\" \"https://swake.io/docs/sdk?framework=react-native\" (match the framework param). Then detect the stack; if no matching SDK exists, STOP and report.\n2. ASK the user (AskUserQuestion tool) for API key (ep_live_...) + base/portal URL + board slug → env + .env.example (placeholders only). Then ASK to confirm which peers to install: required (lucide-react-native, react-native-svg) + optional per feature (async-storage, view-shot, shake, image-picker, webview). Install approved only.\n3. Mount the provider once at the app root, above navigation.\n4. Call init() once, idempotently. With onboarding, set surveys.autoShow:false + init lazily on the first main screen. Theme→your palette; re-init on dark toggle.\n5. identify() with a stable ID + metadata; clearIdentity() on logout.\n6. Route every call via one wrapper (try/catch + configured/initialized guard); feed breadcrumbs. Native: iOS usage strings + pods; strip Android ACTIVITY_RECOGNITION.\n7. Verify on Android AND iOS release builds: a real submission hits the dashboard; no surveys over onboarding. Report changes, manual steps, assumptions.\n```\n\n---\n\n## Optional dependencies\n\nThe SDK is modular. Core features (programmatic `submitFeedback`, identity, notifications, programmatic voting) plus every built-in UI screen work with just the required peers above. These additional packages unlock optional features:\n\n| Feature | Package | Install |\n|---|---|---|\n| Offline queue persistence | `@react-native-async-storage/async-storage` | `npm i @react-native-async-storage/async-storage` |\n| Connectivity-aware flush | `@react-native-community/netinfo` | `npm i @react-native-community/netinfo` |\n| App version auto-detection | `expo-constants` | `npm i expo-constants` |\n| Image attachments | `expo-image-picker` | `npm i expo-image-picker` (or `npx expo install expo-image-picker`) |\n| Screenshot capture | `react-native-view-shot` | `npm i react-native-view-shot` |\n| Shake detection (preferred) | `react-native-shake` | `npm i react-native-shake` |\n| Shake detection (Expo fallback) | `expo-sensors` | `npm i expo-sensors` |\n| **Haptic feedback in surveys** | `expo-haptics` | `npx expo install expo-haptics` |\n| **Survey cooldown persistence** | `@react-native-async-storage/async-storage` | (same package as offline queue above) |\n\nAll optional packages degrade gracefully — the SDK logs a warning if a feature is requested but the required package is missing, and the rest of the SDK continues to function. Optional native modules are loaded lazily (only when the feature is first used), so an absent package — or a JS package whose native module isn't yet linked into the host build — never breaks app boot or the feedback form; the affected control simply hides itself. (For `expo-image-picker` specifically, this means the \"Attachment\" button disappears until the native module is present in the build.)\n\n> **Expo managed workflow:** install `expo-constants`, `expo-sensors`, and any other `expo-*` packages that match your SDK version. For bare workflow, prefer the `react-native-*` variants.\n>\n> **Bare/prebuilt workflow:** installing an optional `expo-*` package updates JS immediately, but its native module only lands after a native rebuild (`expo run:android` / `expo run:ios`). Until then the SDK keeps the feature hidden rather than crashing.\n\n---\n\n## Setup\n\n### 1. Initialise the SDK\n\nCall `Swake.init()` once, as early as possible in your app — typically the top of `App.tsx` or inside your root component before any navigation renders.\n\n```tsx\nimport Swake from '@ahmadqarshi/react-native-swake';\n\nSwake.init({\n  apiKey: 'ep_live_your_key_here',\n});\n```\n\n#### All init options\n\n```tsx\nSwake.init({\n  // Required\n  apiKey: 'ep_live_your_key_here',\n\n  // Optional\n  baseUrl: 'https://api.swake.dev',  // Override API base URL (default shown)\n  debug: false,                          // Log all SDK activity to console\n  shakeToReport: true,                   // Enable shake-to-report gesture (default: true)\n  shakeThreshold: 2.5,                   // Shake intensity threshold in g (default: 2.5)\n  shakeCooldown: 10_000,                 // Ms before shake can trigger again (default: 10000)\n  appVersion: '1.2.3',                   // Override app version (auto-detected if omitted)\n  autoCapture: {\n    deviceInfo: true,                    // Attach OS/device/screen info on every submission\n    networkType: true,                   // Attach network type (wifi/cellular/none)\n  },\n\n  // Plan limit handling (see \"Plan limits\" section)\n  onLimitReachedMode: 'default_ui',      // 'default_ui' | 'silent' | 'callback_only'\n  onLimitReached: (event) => {           // Called when any plan limit is hit\n    console.warn('Swake limit:', event.limitKey, event.message);\n  },\n\n  // Surveys, NPS & polls (see \"Surveys, NPS & polls\" section)\n  surveys: {\n    enabled: true,           // Enable survey eligibility checks (default: true)\n    autoShow: true,          // Auto-show eligible survey after identify() (default: true)\n    delay: 3000,             // Ms to wait after identify() before showing (default: 3000)\n    cooldown: 86_400_000,    // Ms between survey presentations (default: 24 h)\n  },\n\n  // Breadcrumb trails (see \"Breadcrumb trails\" section)\n  breadcrumbs: {\n    enabled: true,            // Enable breadcrumb capture (default: true)\n    maxEntries: 50,           // Ring buffer size, max 100 (default: 50)\n    captureNavigation: true,  // Capture screen changes via setNavigationRef() (default: true)\n    captureNetwork: true,     // Patch fetch / XHR (default: true)\n    captureConsole: true,     // Patch console.error / console.warn (default: true)\n  },\n\n  // Session replay (see \"Session replay\" section)\n  replay: {\n    enabled: false,           // Enable session replay capture (default: false)\n    sampleRate: 1.0,          // 0.0–1.0 fraction of sessions to capture (default: 1.0)\n    maxDurationMs: 30_000,    // Rolling buffer window in ms (default: 30 000 = 30 s)\n    maxBufferBytes: 1_048_576,// Max ring buffer size in bytes, max 2 MB (default: 1 MB)\n    snapshotIntervalMs: 5_000,// Full tree snapshot interval in ms (default: 5 000)\n    maskAllTextInputs: true,  // Always mask TextInput content (default: true)\n  },\n\n  // Customizable trigger FAB (see \"Customizable trigger button\" section)\n  trigger: {\n    position: 'bottom-right', // 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | { x, y }\n    icon: 'chat',              // 'chat' | 'feedback' | 'bug' | 'idea' | { uri: '...' }\n    label: undefined,          // Set a string to render an extended FAB with text\n    hidden: false,             // true = hide FAB, use Swake.open() to trigger manually\n    style: {\n      backgroundColor: '#6366F1',\n      size: 56,\n      borderRadius: 28,\n      shadow: true,\n    },\n  },\n\n  // Global theme (see \"Theme customisation\" section)\n  theme: {\n    primaryColor: '#8B5CF6',\n    dangerColor: '#EF4444',\n    overlayColor: 'rgba(0,0,0,0.5)',\n    borderRadius: 12,\n  },\n});\n```\n\n### 2. Wrap your app in SwakeProvider\n\n`<SwakeProvider>` must be mounted once at the root of your component tree, **above** your navigation stack. It enables the modal feedback form, the bug reporter, the shake-to-report prompt, and the native voting board.\n\n```tsx\nimport { SwakeProvider } from '@ahmadqarshi/react-native-swake';\n\nexport default function App() {\n  return (\n    <SwakeProvider>\n      <NavigationContainer>\n        <RootNavigator />\n      </NavigationContainer>\n    </SwakeProvider>\n  );\n}\n```\n\nIf you use Expo Router, wrap in `app/_layout.tsx`:\n\n```tsx\nimport { SwakeProvider } from '@ahmadqarshi/react-native-swake';\nimport { Stack } from 'expo-router';\n\nexport default function RootLayout() {\n  return (\n    <SwakeProvider>\n      <Stack />\n    </SwakeProvider>\n  );\n}\n```\n\n---\n\n## Identifying users\n\nCall `identify()` after your app authenticates a user. The SDK will attach the user's identity to all future submissions automatically, and will use that identity on the voting board so users can vote without re-authenticating.\n\n```tsx\nawait Swake.identify({\n  userId: 'user_123',         // Required — your internal user ID\n  email: 'jane@example.com', // Optional\n  name: 'Jane Doe',          // Optional\n  metadata: {                // Optional — arbitrary key/value strings\n    plan: 'pro',\n    company: 'Acme Corp',\n    role: 'admin',\n  },\n});\n```\n\nOn logout, clear the identity and wipe the offline queue:\n\n```tsx\nSwake.clearIdentity();\n```\n\n> **Merge behaviour:** `email` and `name` are preserved server-side if not provided on subsequent calls. `metadata` is fully replaced on every call.\n\n### Assigning users to segments via SDK\n\nTo add a user to one or more segments from the SDK, include a `segments` array in `metadata` containing the **slugs** of the segments:\n\n```tsx\nawait Swake.identify({\n  userId: 'user_123',\n  metadata: {\n    plan: 'enterprise',\n    // Assign to named segments by slug\n    segments: ['beta-testers', 'enterprise-customers'],\n  },\n});\n```\n\nSwake will automatically look up segments with matching slugs in the project and add the user as a member (source=`sdk`). Segment slugs are the URL-safe versions of the segment name — e.g. \"Beta Testers\" → `beta-testers`. You can find them in the Portal under **Project → Segments**.\n\n> **Note:** Rule-based segments are evaluated automatically after every `identify()` call — no `segments` key needed. The `segments` key only applies to manual or SDK-driven membership overrides.\n\n---\n\n## Submitting feedback\n\nSubmit feedback programmatically from anywhere in your code:\n\n```tsx\nimport Swake from '@ahmadqarshi/react-native-swake';\n\nconst submission = await Swake.submitFeedback({\n  type: 'bug',                        // 'bug' | 'feature' | 'question'\n  title: 'App crashes on photo upload',\n  description: 'Steps to reproduce:\\n1. Open camera\\n2. Take photo\\n3. Crash',\n});\n\nconsole.log(submission.id); // ULID, e.g. '01HW...'\n```\n\n### With file attachments\n\n```tsx\nconst submission = await Swake.submitFeedback({\n  type: 'bug',\n  title: 'Wrong price displayed',\n  attachments: [\n    {\n      uri: 'file:///path/to/screenshot.jpg', // Local file URI\n      filename: 'screenshot.jpg',\n      mimeType: 'image/jpeg',\n    },\n  ],\n});\n```\n\nEvery call to `submitFeedback()` goes through the offline queue — it is safe to call even when the device is offline. The item will be retried automatically when connectivity is restored.\n\n---\n\n## UI components\n\n### SwakeProvider\n\nThe provider mounts invisibly and manages all SDK modal overlays:\n\n| Overlay | Trigger |\n|---|---|\n| Feedback form | `Swake.showFeedbackForm()` |\n| Bug reporter | `Swake.showBugReporter()` |\n| Shake prompt | Physical shake gesture |\n| Submission list | `Swake.showSubmissions()` |\n| Submission detail | `Swake.showSubmissionDetail()` |\n| **Voting board** | **`Swake.openVotingBoard()`** |\n| **Survey / NPS** | **Auto after `identify()` · `Swake.checkSurveys()` · `Swake.showSurvey(id)`** |\n\nMount it once at the root. No props required.\n\n---\n\n### Feedback form modal\n\nA full-screen modal (bottom sheet on iOS) with a type selector, title/description fields, optional image attachments, and offline awareness.\n\n**Programmatic open:**\n\n```tsx\n// Default — opens on Bug tab\nSwake.showFeedbackForm();\n\n// Pre-select a type\nSwake.showFeedbackForm({ defaultType: 'feature' });\n\n// Custom header text and placeholder\nSwake.showFeedbackForm({\n  title: 'Tell us what you think',\n  placeholder: 'I noticed that…',\n});\n\n// Hide the Bug / Idea / Help type selector\nSwake.showFeedbackForm({ showTypeSelector: false });\n\n// Custom theme\nSwake.showFeedbackForm({\n  theme: { primaryColor: '#8B5CF6', borderRadius: 12 },\n});\n```\n\n**Via a button in your UI:**\n\n```tsx\nimport { Pressable, Text } from 'react-native';\nimport Swake from '@ahmadqarshi/react-native-swake';\n\nfunction FeedbackButton() {\n  return (\n    <Pressable onPress={() => Swake.showFeedbackForm()}>\n      <Text>Send feedback</Text>\n    </Pressable>\n  );\n}\n```\n\n---\n\n### Embedded feedback form\n\nEmbed the form directly inside a screen instead of as a modal, using the `<SwakeFeedbackForm>` component in controlled-visible mode, or the raw `<FeedbackFormBody>` component.\n\n**`<SwakeFeedbackForm>` (modal or visible inline):**\n\n```tsx\nimport { SwakeFeedbackForm } from '@ahmadqarshi/react-native-swake';\n\nfunction FeedbackScreen() {\n  const [open, setOpen] = React.useState(false);\n\n  return (\n    <>\n      <Button title=\"Feedback\" onPress={() => setOpen(true)} />\n      <SwakeFeedbackForm\n        visible={open}\n        onClose={() => setOpen(false)}\n        defaultType=\"bug\"\n        showTypeSelector\n        title=\"Found a problem?\"\n        theme={{ primaryColor: '#10B981' }}\n        onSubmit={(submission) => {\n          console.log('Submitted:', submission.id);\n        }}\n      />\n    </>\n  );\n}\n```\n\n**`<FeedbackFormBody>` (fully embedded, no modal chrome):**\n\nPlace the form body directly inside your own screen layout:\n\n```tsx\nimport { FeedbackFormBody } from '@ahmadqarshi/react-native-swake';\n\nfunction InlineFeedbackScreen() {\n  return (\n    <SafeAreaView style={{ flex: 1 }}>\n      <FeedbackFormBody\n        defaultType=\"feature\"\n        showTypeSelector\n        theme={{ primaryColor: '#6366F1' }}\n        onSubmit={(submission) => console.log('Done', submission.id)}\n        onClose={() => navigation.goBack()}\n      />\n    </SafeAreaView>\n  );\n}\n```\n\n#### FeedbackFormBody / SwakeFeedbackForm props\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| `defaultType` | `'bug' \\| 'feature' \\| 'question'` | `'bug'` | Pre-selected feedback type |\n| `showTypeSelector` | `boolean` | `true` | Show the Bug / Idea / Help type picker |\n| `title` | `string` | `\"What's on your mind?\"` | Header text above the form |\n| `placeholder` | `string` | Steps to reproduce… | Description field placeholder |\n| `theme` | `SwakeTheme` | — | Theme overrides (see [Theme customisation](#theme-customisation)) |\n| `onSubmit` | `(submission: Submission) => void` | — | Called after successful submission |\n| `onClose` | `() => void` | — | Called when the user taps the close/dismiss button |\n| `visible` | `boolean` | — | (`SwakeFeedbackForm` only) Controls modal visibility |\n\n---\n\n### Bug reporter with screenshot annotation\n\nOpens a multi-phase flow:\n\n1. **Screenshot** — the current screen is captured automatically before the modal opens\n2. **Annotation** — freehand drawing, ellipse, and rectangle tools; 4 color presets; undo\n3. **Form** — title and description fields; the annotated screenshot is attached automatically\n4. **Submission** — goes through the offline queue like any other submission\n\n**Requires:** `react-native-view-shot` + `react-native-svg`\n\n```tsx\n// Open programmatically\nSwake.showBugReporter();\n\n// With theme override\nSwake.showBugReporter({ theme: { primaryColor: '#EF4444' } });\n```\n\n**Via a long-press gesture or dedicated button:**\n\n```tsx\n<Pressable onLongPress={() => Swake.showBugReporter()}>\n  <Text>Report a bug</Text>\n</Pressable>\n```\n\n#### Annotation tools\n\n| Tool | Description |\n|---|---|\n| Freehand (`✏️`) | Free-form pen strokes |\n| Ellipse (`⬭`) | Tap-drag to draw circles/ovals |\n| Rectangle (`▭`) | Tap-drag to draw rectangles |\n| Colors | Red · Yellow · Blue · Black |\n| Undo | Remove the last annotation |\n\n---\n\n### Shake-to-report\n\nWhen enabled, shaking the device shows a floating prompt (\"Shake detected! Want to report a bug?\"). Tapping **Report →** captures a screenshot and opens the bug reporter.\n\nShake detection is enabled by default. It requires either `react-native-shake` (preferred) or `expo-sensors` (Expo fallback). If neither is installed, the SDK logs a warning and shake detection is silently disabled.\n\n**Anti-annoyance guards:**\n\n- **Cooldown** — after a trigger, no further triggers for `shakeCooldown` ms (default 10 000 ms)\n- **Debounce** — a burst of shake signals collapses to a single trigger within a 500 ms window\n- **Keyboard guard** — does not trigger while a `TextInput` is focused (avoids iOS undo conflict)\n- **AppState guard** — does not trigger when the app is backgrounded\n\n**Runtime toggle:**\n\n```tsx\n// Disable during onboarding or sensitive flows\nSwake.setShakeToReport(false);\n\n// Re-enable\nSwake.setShakeToReport(true);\n```\n\n**Disable at init time:**\n\n```tsx\nSwake.init({\n  apiKey: '...',\n  shakeToReport: false,\n});\n```\n\n**Tune sensitivity:**\n\n```tsx\nSwake.init({\n  apiKey: '...',\n  shakeThreshold: 3.0,      // Higher = requires harder shake (default 2.5)\n  shakeCooldown: 30_000,    // 30 s between triggers (default 10 s)\n});\n```\n\n---\n\n### Submission list & detail screens\n\nTwo full-screen native modals that let users browse their own submissions, filter by status, view individual submission details with the full comment thread, and reply — all without leaving your app.\n\n**Requires:** `identify()` must have been called so the SDK knows which user to load submissions for.\n\n#### Submission list\n\nOpens a scrollable list of the user's past submissions with status filter tabs (All / Open / In Progress / Resolved / Closed), pull-to-refresh, and infinite scroll.\n\n```tsx\n// Open the list\nSwake.showSubmissions();\n\n// With theme override\nSwake.showSubmissions({ theme: { primaryColor: '#8B5CF6' } });\n```\n\nVia a button:\n\n```tsx\n<Pressable onPress={() => Swake.showSubmissions()}>\n  <Text>My reports</Text>\n</Pressable>\n```\n\n#### Submission detail\n\nOpens a single submission directly by ID — useful for deep-linking from a notification or a custom list UI. Shows the submission card (title, status, description, device info), attachments (tap to open full-screen), the full comment thread, and a reply input.\n\n```tsx\nSwake.showSubmissionDetail({ submissionId: 'sub_01HW...' });\n\n// Deep-link from a notification:\nconst result = await Swake.listNotifications({ unreadOnly: true });\nif (result.notifications[0]?.submissionId) {\n  Swake.showSubmissionDetail({\n    submissionId: result.notifications[0].submissionId,\n  });\n}\n```\n\n#### Reply / comment thread\n\nThe detail screen includes a reply input at the bottom. Replies go through the **offline queue** — they are sent immediately when online, or persisted and retried automatically when offline. An optimistic comment appears in the thread instantly before the server confirms.\n\n#### Translated team replies\n\nWhen a workspace has auto-translation enabled (BOL-136), team replies are automatically reverse-translated into the end-user's language before the SDK displays them. The submission detail screen handles this transparently:\n\n- **Translated replies** show a small globe badge (\"🌐 Translated from EN\") below the author name.\n- A **\"View original\"** tappable link appears beneath the comment body. Tapping it fades in the team member's original text.\n- Tapping again shows **\"View translation\"** to return to the translated text.\n- If translation is **pending**, a \"🌐 Translating…\" indicator is shown in place of the badge.\n- If translation **failed or was skipped**, the original text is shown with no indicator.\n\nNo SDK configuration is required — translation is driven entirely by the backend and the `SubmissionComment` fields returned by the API.\n\n#### `showSubmissions` options\n\n| Prop | Type | Description |\n|---|---|---|\n| `theme` | `SwakeTheme` | Theme overrides (see [Theme customisation](#theme-customisation)) |\n\n#### `showSubmissionDetail` options\n\n| Prop | Type | Required | Description |\n|---|---|---|---|\n| `submissionId` | `string` | ✓ | ID of the submission to display |\n| `theme` | `SwakeTheme` | | Theme overrides |\n\n---\n\n## Voting boards\n\nVoting boards let your users upvote feature requests and see your product roadmap — all without leaving your app. The SDK provides two integration approaches:\n\n| Approach | When to use |\n|---|---|\n| **Native board** (`openVotingBoard`) | Drop-in — renders a native voting board screen (item list + upvote buttons) with automatic identity |\n| **Programmatic API** (`vote` / `removeVote` / `getBoardItems`) | Custom UI — build your own native voting experience using SDK data methods |\n\n---\n\n### Native voting board\n\n`openVotingBoard()` opens the public voting board as a **native React Native screen** — no WebView — mirroring the web public portal. It renders:\n\n- **Board list** — each item shows an upvote button, title, a **Pinned** badge, a status badge with roadmap labels (`open → Planned`, `in_progress → In Progress`, `resolved → Shipped`, `closed → Closed`), a type badge (Feature / Bug / Question / Other), a description snippet, and the comment count. A **Most voted / Most recent** sort toggle and status filter chips (All / Planned / In Progress / Shipped) sit above the list.\n- **Item detail** — tap any item to open a native detail screen with the vote box + title + status pill, the author (avatar + full description + date), a status stepper (Planned → In Progress → Shipped), a voters list, and the full **comment thread** with replies, likes, and a composer.\n\nData is fetched from the public board API and all mutations (vote, comment, like) use the identified user's identity.\n\n**Requires:**\n- `<SwakeProvider>` mounted at your app root\n- `Swake.identify(...)` called before the user can vote, comment, or like (browsing works without it)\n\nNo `react-native-webview` is needed — the board is fully native.\n\n#### Open the voting board\n\n```tsx\n// Open by board slug (the slug you set when creating the board in the portal)\nSwake.openVotingBoard('public-roadmap');\n\n// With theme overrides\nSwake.openVotingBoard('public-roadmap', { theme: { colorScheme: 'dark' } });\nSwake.openVotingBoard('public-roadmap', { theme: { colorScheme: 'system' } }); // follows device setting (default)\n```\n\nVia a button in your settings or profile screen:\n\n```tsx\nimport { Pressable, Text } from 'react-native';\nimport Swake from '@ahmadqarshi/react-native-swake';\n\nfunction RoadmapButton() {\n  return (\n    <Pressable onPress={() => Swake.openVotingBoard('public-roadmap')}>\n      <Text>Vote on features</Text>\n    </Pressable>\n  );\n}\n```\n\n#### How voting works\n\nWhen the user taps an upvote button, the SDK calls the public vote endpoint with the identified user's `externalUserId` and your project API key — so voting is one-tap with no email verification prompt. The count updates optimistically and reconciles with the server response. Tapping again removes the vote.\n\n```\nSwake.identify({ userId: 'user_123', email: 'jane@example.com' })\n  └─▶ openVotingBoard('roadmap')\n        └─▶ native list loads GET /v1/public/boards/roadmap  (per-item hasVoted resolved from identity)\n              └─▶ User taps ▲ Vote → POST …/vote { externalUserId } → instant, no email required\n```\n\nIf the user has **not** been identified, the board still opens and items are browsable; tapping vote is a no-op (the SDK logs a warning in `debug` mode). Call `Swake.identify()` first to enable voting.\n\n#### openVotingBoard options\n\n| Option | Type | Default | Description |\n|---|---|---|---|\n| `theme` | `SwakeTheme` | follows global theme | Theme overrides. `colorScheme` (`'light' \\| 'dark' \\| 'system'`) controls the board's light/dark appearance. |\n\n---\n\n### Programmatic voting API\n\nFor teams who want to build a fully native voting UI inside their app. These methods are thin wrappers around the public board API endpoints.\n\n**Requirements:** `Swake.identify()` must have been called for `vote()` and `removeVote()`. `getBoardItems()` is public and requires no authentication.\n\n#### getBoardItems\n\nFetch the visible items on a board. Returns items sorted by the board's default sort order (configurable in the portal).\n\n```tsx\n// Fetch all items\nconst { items, hasMore } = await Swake.getBoardItems('public-roadmap');\n\n// With options\nconst { items } = await Swake.getBoardItems('public-roadmap', {\n  sort: 'recent',          // 'votes' (default) | 'recent'\n  status: 'in_progress',  // Filter by status: 'open' | 'in_progress' | 'resolved' | 'closed'\n  page: 2,                 // Page number (20 items per page, 1-based)\n});\n\n// Render items\nitems.forEach((item) => {\n  console.log(item.submission.title, item.voteCount, item.pinned);\n});\n```\n\n#### vote\n\nCast a vote on a board item. Works on boards in both `email_verified` and `authenticated` access modes when the user has been identified via the SDK.\n\n```tsx\nconst result = await Swake.vote('public-roadmap', itemId);\n\nswitch (result.status) {\n  case 'voted':\n    console.log(`Voted! New count: ${result.voteCount}`);\n    break;\n  case 'already_voted':\n    console.log(`Already voted. Current count: ${result.voteCount}`);\n    break;\n  case 'error':\n    console.log('Vote failed — user may not be identified.');\n    break;\n}\n```\n\n#### removeVote\n\nRemove a previously cast vote.\n\n```tsx\nconst result = await Swake.removeVote('public-roadmap', itemId);\n\nswitch (result.status) {\n  case 'removed':\n    console.log(`Vote removed. New count: ${result.voteCount}`);\n    break;\n  case 'not_voted':\n    console.log('No vote to remove.');\n    break;\n  case 'error':\n    console.log('Failed.');\n    break;\n}\n```\n\n---\n\n### Custom board UI\n\nHere's a complete example of a native voting screen built entirely with the programmatic API:\n\n```tsx\nimport React, { useEffect, useState } from 'react';\nimport {\n  View,\n  Text,\n  FlatList,\n  Pressable,\n  ActivityIndicator,\n  StyleSheet,\n} from 'react-native';\nimport Swake from '@ahmadqarshi/react-native-swake';\nimport type { BoardItem } from '@ahmadqarshi/react-native-swake';\n\nexport function RoadmapScreen() {\n  const [items, setItems] = useState<BoardItem[]>([]);\n  const [loading, setLoading] = useState(true);\n  const [votedIds, setVotedIds] = useState<Set<string>>(new Set());\n  const [voteCounts, setVoteCounts] = useState<Record<string, number>>({});\n\n  useEffect(() => {\n    Swake.getBoardItems('public-roadmap')\n      .then(({ items }) => {\n        setItems(items);\n        setVoteCounts(Object.fromEntries(items.map((i) => [i.id, i.voteCount])));\n      })\n      .finally(() => setLoading(false));\n  }, []);\n\n  async function handleVote(item: BoardItem) {\n    const hasVoted = votedIds.has(item.id);\n\n    if (hasVoted) {\n      const result = await Swake.removeVote('public-roadmap', item.id);\n      if (result.status === 'removed') {\n        setVotedIds((prev) => {\n          const next = new Set(prev);\n          next.delete(item.id);\n          return next;\n        });\n        setVoteCounts((prev) => ({ ...prev, [item.id]: result.voteCount }));\n      }\n    } else {\n      const result = await Swake.vote('public-roadmap', item.id);\n      if (result.status === 'voted' || result.status === 'already_voted') {\n        setVotedIds((prev) => new Set([...prev, item.id]));\n        setVoteCounts((prev) => ({ ...prev, [item.id]: result.voteCount }));\n      }\n    }\n  }\n\n  if (loading) {\n    return <ActivityIndicator style={styles.center} />;\n  }\n\n  return (\n    <FlatList\n      data={items}\n      keyExtractor={(item) => item.id}\n      contentContainerStyle={styles.list}\n      renderItem={({ item }) => {\n        const hasVoted = votedIds.has(item.id);\n        return (\n          <View style={styles.card}>\n            <View style={styles.cardBody}>\n              <Text style={styles.title}>{item.submission.title}</Text>\n              {item.submission.description ? (\n                <Text style={styles.desc} numberOfLines={2}>\n                  {item.submission.description}\n                </Text>\n              ) : null}\n            </View>\n            <Pressable\n              style={[styles.voteBtn, hasVoted && styles.voteBtnActive]}\n              onPress={() => handleVote(item)}\n            >\n              <Text style={[styles.voteCount, hasVoted && styles.voteCountActive]}>\n                ▲ {voteCounts[item.id] ?? item.voteCount}\n              </Text>\n            </Pressable>\n          </View>\n        );\n      }}\n    />\n  );\n}\n\nconst styles = StyleSheet.create({\n  center: { flex: 1, justifyContent: 'center', alignItems: 'center' },\n  list: { padding: 16, gap: 12 },\n  card: {\n    flexDirection: 'row',\n    alignItems: 'center',\n    backgroundColor: '#fff',\n    borderRadius: 12,\n    padding: 16,\n    gap: 12,\n    shadowColor: '#000',\n    shadowOpacity: 0.06,\n    shadowRadius: 4,\n    elevation: 2,\n  },\n  cardBody: { flex: 1 },\n  title: { fontSize: 15, fontWeight: '600', color: '#0f172a' },\n  desc: { fontSize: 13, color: '#64748b', marginTop: 4, lineHeight: 18 },\n  voteBtn: {\n    paddingVertical: 8,\n    paddingHorizontal: 14,\n    borderRadius: 8,\n    borderWidth: 1.5,\n    borderColor: '#e2e8f0',\n    backgroundColor: '#f8fafc',\n    alignItems: 'center',\n  },\n  voteBtnActive: {\n    borderColor: '#10b981',\n    backgroundColor: '#ecfdf5',\n  },\n  voteCount: { fontSize: 13, fontWeight: '600', color: '#64748b' },\n  voteCountActive: { color: '#10b981' },\n});\n```\n\n#### BoardItem type\n\n```ts\ninterface BoardItem {\n  id: string;\n  submissionId: string;\n  pinned: boolean;\n  voteCount: number;\n  submission: {\n    title: string | null;\n    description: string | null;\n    type: string | null;   // 'bug' | 'feature' | 'question' | 'other' | null\n    status: string | null; // 'open' | 'in_progress' | 'resolved' | 'closed' | null\n  };\n  createdAt: string | null; // ISO 8601\n}\n```\n\n---\n\n## Surveys, NPS & polls\n\nSwake can display surveys, NPS prompts, and inline polls directly inside your app. Surveys are created and targeted in the portal; the SDK handles eligibility evaluation, rendering, and response submission automatically.\n\nRequires: `<SwakeProvider>` mounted at your app root, and `identify()` to have been called so targeting rules can be evaluated.\n\n---\n\n### Automatic eligibility check\n\nAfter every `identify()` call the SDK silently checks for eligible surveys. If one is found and `autoShow` is enabled, it appears after the configured delay (default 3 s). Only one survey is shown per session, and a cooldown (default 24 h) prevents showing another too soon.\n\n```tsx\n// identify() triggers the eligibility check automatically:\nawait Swake.identify({ userId: 'user_123' });\n// → SDK checks eligibility after 3 s (default delay)\n// → If an eligible survey exists, it is shown\n```\n\n---\n\n### Survey configuration\n\nConfigure survey behaviour inside `Swake.init()`:\n\n```tsx\nSwake.init({\n  apiKey: 'ep_live_...',\n  surveys: {\n    enabled: true,         // Set false to disable all surveys. Default: true\n    autoShow: true,        // Show automatically after identify(). Default: true\n    delay: 5000,           // Ms after identify() before showing. Default: 3000\n    cooldown: 43_200_000,  // Ms between two survey shows (12 h). Default: 86400000 (24 h)\n  },\n});\n```\n\n| Option | Type | Default | Description |\n|---|---|---|---|\n| `enabled` | `boolean` | `true` | Master switch — disables all survey checks and rendering when `false` |\n| `autoShow` | `boolean` | `true` | Automatically show the survey after `identify()` completes |\n| `delay` | `number` | `3000` | Milliseconds to wait after `identify()` before showing the survey |\n| `cooldown` | `number` | `86400000` | Minimum milliseconds between two survey presentations (persisted across app launches via AsyncStorage) |\n\n---\n\n### Presentation modes\n\nSurveys support three presentation modes, configured when the survey is created in the portal:\n\n| Mode | Behaviour |\n|---|---|\n| `bottom_sheet` | Slide-up sheet covering ~62% of the screen. Draggable to dismiss. |\n| `full_screen` | Full-screen native modal (`presentationStyle=\"fullScreen\"`). |\n| `inline` | Rendered as a card in your layout via `<Swake.PollWidget />`. Never shown via modal. |\n\nThe SDK picks the correct presentation mode automatically based on the survey's `presentation` field. Your app code does not need to branch on this.\n\n---\n\n### Question types\n\n| Type | Rendered as |\n|---|---|\n| `text` | Multi-line text input |\n| `rating` | 1–5 star row (filled/unfilled, haptic feedback) |\n| `nps` | 0–10 button grid — red (0–6), amber (7–8), green (9–10) |\n| `single_choice` | Radio-style option list (one selection) |\n| `multiple_choice` | Checkbox-style option list (multi-select) |\n\n`multiple_choice` is a **multi-select** question: the respondent may pick several options in one submission. The portal sets a per-question selection cap (`maxSelections`) — once reached, the remaining options disable and a \"Select up to N of M\" hint appears. Each selected option is submitted as its own answer (the server tallies per option), so a multi-select answer produces one entry per chosen option, all sharing the question's id.\n\nMulti-question surveys show progress dots and Back/Next navigation. Required questions disable the Next button until answered.\n\n---\n\n### Inline poll widget\n\n`<Swake.PollWidget>` is a self-contained card component for placing polls directly inside your screen layout. It loads the first eligible inline poll automatically, shows tappable options before voting, and switches to Twitter-style percentage bars after the user votes.\n\nThe widget adapts to the poll's question type automatically: a **single-choice** poll casts the vote as soon as an option is tapped, while a **multi-select** (`multiple_choice`) poll lets the user toggle several options (up to the portal's per-question cap, with a \"Select up to N of M\" hint) and submit them together with a **Vote** button.\n\n```tsx\nimport { SwakeProvider, PollWidget } from '@ahmadqarshi/react-native-swake';\n\n// Auto-pick the first eligible inline poll:\n<PollWidget\n  style={{ margin: 16 }}\n  onVote={(optionIndex) => console.log('Voted:', optionIndex)}\n  onDismiss={() => console.log('Dismissed')}\n/>\n\n// Show a specific poll by ID:\n<PollWidget\n  surveyId=\"01HW...\"\n  style={{ margin: 16 }}\n  theme={{ primaryColor: '#8B5CF6' }}\n/>\n```\n\n#### PollWidget props\n\n| Prop | Type | Description |\n|---|---|---|\n| `surveyId` | `string` | Show a specific poll by ID. Omit to auto-pick the first eligible inline poll. |\n| `style` | `ViewStyle` | Extra styles applied to the card container. |\n| `onVote` | `(optionIndex: number) => void` | Called when the vote is cast (before the API response). For a multi-select poll it fires once with the first selected option's index. |\n| `onDismiss` | `() => void` | Called when the user dismisses the poll. |\n| `theme` | `SwakeTheme` | Theme overrides (see [Theme customisation](#theme-customisation)). |\n\n**Before voting:**\n\nThe widget shows each option as a tappable `Pressable` button. The selected option is highlighted with the primary colour border.\n\n**After voting:**\n\nPercentage bars appear behind each option showing live vote distribution (optimistic — updated immediately before the API responds). The total vote count is shown in the footer.\n\n---\n\n### Survey callbacks\n\nRegister callbacks to track survey lifecycle events:\n\n```tsx\n// Called when a survey is displayed to the user\nSwake.onSurveyShown((event) => {\n  console.log('Survey shown:', event.id, event.type);\n  Analytics.track('survey_shown', { surveyId: event.id });\n});\n\n// Called when the user submits all answers\nSwake.onSurveyCompleted((event) => {\n  console.log('Survey completed:', event.survey.id);\n  console.log('Answers:', event.response.answers);\n  Analytics.track('survey_completed', { surveyId: event.survey.id });\n});\n\n// Called when the user dismisses the survey without completing it\nSwake.onSurveyDismissed((event) => {\n  console.log('Survey dismissed:', event.id);\n});\n```\n\nCallbacks can also be registered at init time:\n\n```tsx\nSwake.init({\n  apiKey: 'ep_live_...',\n  onSurveyShown: (event) => { /* ... */ },\n  onSurveyCompleted: (event) => { /* ... */ },\n  onSurveyDismissed: (event) => { /* ... */ },\n});\n```\n\n#### Event types\n\n**`SurveyShownEvent`** / **`SurveyDismissedEvent`**\n\n| Field | Type | Description |\n|---|---|---|\n| `id` | `string` | Survey ID |\n| `type` | `SurveyType` | `'survey'` \\| `'nps'` \\| `'poll'` |\n\n**`SurveyCompletedEvent`**\n\n| Field | Type | Description |\n|---|---|---|\n| `survey.id` | `string` | Survey ID |\n| `survey.type` | `SurveyType` | Survey type |\n| `response.answers` | `SurveyAnswer[]` | Array of `{ questionId, value }` pairs |\n\n---\n\n### Manual survey methods\n\n```tsx\n// Manually trigger an eligibility check (busts the 5-minute API cache)\nawait Swake.checkSurveys();\n\n// Show a specific survey by ID, bypassing the cooldown (useful for testing)\nawait Swake.showSurvey('01HW...');\n```\n\n**`Swake.checkSurveys(): Promise<void>`**\n\nForces an eligibility check immediately. Busts the local cooldown guard so the check always hits the API. If an eligible survey is found and `autoShow` is `true`, it is displayed.\n\n**`Swake.showSurvey(surveyId: string): Promise<void>`**\n\nFetch and display a specific survey by ID. Bypasses the session-shown guard and cooldown — useful for testing specific surveys in development or for flows where you control the trigger explicitly.\n\n---\n\n## Notifications\n\n### In-app notifications\n\nRetrieve in-app notifications for the current user (e.g. team replies to their submissions).\n\n```tsx\n// Unread count badge\nconst count = await Swake.getUnreadCount();\n\n// List notifications (paginated)\nconst result = await Swake.listNotifications({ unreadOnly: true, limit: 20 });\n// result.notifications — Notification[]\n// result.pagination    — { hasMore: boolean, cursor: string | null }\n\n// Next page\nconst next = await Swake.listNotifications({ cursor: result.pagination.cursor });\n\n// Mark one read\nawait Swake.markNotificationRead(notification.id);\n\n// Mark all read\nawait Swake.markAllNotificationsRead();\n```\n\n#### Notification object\n\n```ts\ninterface Notification {\n  id: string;\n  type: string;\n  title: string;\n  body: string | null;\n  isRead: boolean;\n  createdAt: string;           // ISO 8601\n  submissionId: string | null; // Link to the related submission\n}\n```\n\n### Push token registration\n\nRegister a device push token so Swake can deliver push notifications to this device. Call this **after** both `Swake.identify()` and the OS push permission grant.\n\n```tsx\nimport * as Notifications from 'expo-notifications'; // or your preferred push library\n\n// After identify() and permission grant\nconst token = (await Notifications.getExpoPushTokenAsync()).data;\nawait Swake.registerPushToken('expo', token);\n\n// APNs (iOS bare workflow)\nconst apnsToken = await getAPNSToken(); // platform-specific\nawait Swake.registerPushToken('apns', apnsToken);\n\n// FCM (Android)\nimport messaging from '@react-native-firebase/messaging';\nconst fcmToken = await messaging().getToken();\nawait Swake.registerPushToken('fcm', fcmToken);\n```\n\nThe call is idempotent — calling it multiple times with the same token is safe.\n\nCall `unregisterPushToken()` on logout or when the user revokes push permission so the token is deactivated server-side:\n\n```tsx\nawait Swake.unregisterPushToken(token);\n```\n\n> **Note:** Push delivery (APNs/FCM/Expo Send API) is not yet implemented. Tokens are stored and ready for Phase 2. Team member email notifications for new submissions, comments, status changes, and assignments are already live.\n\n---\n\n## Breadcrumb trails\n\nBreadcrumbs are a chronological trail of events (navigation, network, console) automatically captured by the SDK and attached to every bug report. They give your team full context on what the user was doing right before a bug was reported — without any extra code.\n\nBreadcrumbs are stored in an in-memory ring buffer (FIFO). When the buffer is full, the oldest entry is silently dropped.\n\n### Auto-capture\n\nBreadcrumb capture is **enabled by default**. Configure it in `Swake.init()`:\n\n```tsx\nSwake.init({\n  apiKey: '...',\n  breadcrumbs: {\n    enabled: true,          // Enable/disable entirely. Default: true\n    maxEntries: 50,         // Ring buffer size. Default: 50, max: 100\n    captureNavigation: true, // Capture screen changes. Default: true\n    captureNetwork: true,    // Capture fetch / XHR requests. Default: true\n    captureConsole: true,    // Capture console.error / console.warn. Default: true\n  },\n});\n```\n\n**Network breadcrumbs** monkey-patch `global.fetch` and `XMLHttpRequest`. Swake's own API calls are automatically excluded. Request/response bodies are never captured.\n\n**Console breadcrumbs** intercept `console.error` and `console.warn`. The original implementations are always called first — this is purely additive.\n\n### Navigation breadcrumbs\n\nWire the SDK into React Navigation's `onStateChange` to auto-capture screen transitions:\n\n```tsx\nimport Swake from '@ahmadqarshi/react-native-swake';\nimport { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native';\n\nexport default function App() {\n  const navRef = useNavigationContainerRef();\n\n  // Pass the ref once on init and again on every state change\n  Swake.setNavigationRef(navRef);\n\n  return (\n    <SwakeProvider>\n      <NavigationContainer\n        ref={navRef}\n        onStateChange={() => Swake.setNavigationRef(navRef)}\n      >\n        <RootNavigator />\n      </NavigationContainer>\n    </SwakeProvider>\n  );\n}\n```\n\nEach screen change adds a breadcrumb like:\n```json\n{\n  \"category\": \"navigation\",\n  \"message\": \"Navigated to ProfileScreen\",\n  \"data\": { \"from\": \"HomeScreen\", \"to\": \"ProfileScreen\" },\n  \"timestamp\": \"2026-03-24T10:00:00Z\"\n}\n```\n\n### Manual breadcrumbs\n\nAdd your own breadcrumbs for user actions or custom events:\n\n```tsx\n// User action\nSwake.addBreadcrumb({\n  category: 'user_action',\n  message: 'Tapped checkout button',\n});\n\n// Custom event with data\nSwake.addBreadcrumb({\n  category: 'custom',\n  message: 'Feature flag evaluated',\n  data: { flag: 'dark_mode', value: true },\n});\n```\n\nSupported categories: `'navigation'` | `'network'` | `'console'` | `'custom'`.\n\n### Breadcrumbs in bug reports\n\nWhen a user submits a **bug report** (type `'bug'` via `submitFeedback()` or `showBugReporter()`), the current breadcrumb trail is automatically serialized and included in the submission payload as `metadata.breadcrumbs`. This is transparent — no extra code required.\n\n```tsx\n// Breadcrumbs are attached automatically\nawait Swake.submitFeedback({\n  type: 'bug',\n  title: 'App crashes on login',\n});\n// → API payload includes: metadata.breadcrumbs: [...]\n```\n\nTo read the current trail manually:\n\n```tsx\nconst crumbs = Swake.getBreadcrumbs(); // Breadcrumb[]\nSwake.clearBreadcrumbs();              // Reset the buffer\n```\n\n---\n\n## Session replay\n\nSession replay records the last 30 seconds of user interaction before a bug report as a lightweight event stream. The replay is reconstructed in the Swake portal using a custom canvas-based player, giving your team a visual timeline of exactly what the user was doing — without ever capturing raw pixels or video.\n\n### How it works\n\nThe SDK uses an **event-based approach** (similar to Sentry Session Replay and LogRocket), not screen recording:\n\n1. A background **tree serializer** walks the React fiber tree every 5 seconds to capture a `tree_snapshot` — a JSON description of the component hierarchy and visual style properties.\n2. Between snapshots, **incremental mutations** (`tree_mutation`) are emitted when the tree changes.\n3. Interaction events (`touch`, `scroll`, `navigation`, `keyboard`, `network`) are collected by lightweight collectors that hook into React Native's own APIs — no method swizzling or private internals.\n4. All events pass through the **privacy engine** before being written to a **30-second ring buffer**. The privacy engine masks sensitive data before it ever touches the buffer.\n5. When a bug report is submitted, the ring buffer is flushed, compressed (gzip), and uploaded to R2 as `replays/{project_id}/{id}.json`.\n6. The portal player fetches the compressed JSON, decompresses it, and renders the component tree on a `<canvas>` element inside a device frame.\n\n```\nUser interaction\n      │\n      ▼\n  Collectors (touch / scroll / nav / keyboard / network)\n      │\n      ▼\n  Privacy Engine  ←── masks TextInputs + echoPostMask props\n      │\n      ▼\n  Ring Buffer (rolling 30 s / 1 MB)\n      │\n      ▼  (on bug report submission)\n  Flush → gzip → upload to R2\n      │\n      ▼\n  Portal player reconstructs the session from JSON\n```\n\n### Quick start\n\nEnable session replay in `Swake.init()`:\n\n```tsx\nimport Swake from '@ahmadqarshi/react-native-swake';\n\nSwake.init({\n  apiKey: 'ep_live_your_key_here',\n  replay: {\n    enabled: true,\n    sampleRate: 1.0, // Capture 100% of sessions (reduce in production)\n  },\n});\n```\n\nThat's it. When the user submits a **bug report** (via `submitFeedback({ type: 'bug' })`, `showBugReporter()`, or shake-to-report), the SDK automatically attaches the replay to the submission. The portal shows a **\"Watch Replay\"** button on the submission detail page.\n\nTo sample only 20% of sessions (recommended for high-traffic apps):\n\n```tsx\nreplay: {\n  enabled: true,\n  sampleRate: 0.2, // Record 1 in 5 sessions\n}\n```\n\n### Privacy controls\n\nPrivacy is enforced **before** any data touches the ring buffer. There is no opt-out for end users — the SDK is privacy-first by design.\n\n#### Default behaviour\n\n| Data type | Default handling |\n|---|---|\n| `TextInput` content | **Always masked** — replaced with `***` regardless of any other setting |\n| Component tree structure | Captured (component names, layout, style) |\n| Touch coordinates | Captured (x, y relative to screen) |\n| Navigation routes | Captured (screen name only) |\n| Network requests | URL captured (query params stripped); method + status code captured; **request/response bodies never captured** |\n| Keyboard show/hide events | Captured (timing only, no key presses) |\n| Images | Rendered as gray placeholder; image URIs are not captured |\n\n#### Privacy props\n\nAdd these props to any React Native component to control how it is handled by the privacy engine:\n\n```tsx\n// Mask this component and all its children (content replaced with gray block)\n<View echoPostMask>\n  <Text>Sensitive content</Text>\n</View>\n\n// Unmask a child of a masked ancestor\n<View echoPostMask>\n  <Text echoPostUnmask>This text will be visible in replays</Text>\n</View>\n\n// Block this component entirely — it is excluded from the tree snapshot\n// as if it does not exist. Use for truly sensitive UI (e.g. card numbers).\n<TextInput echoPostBlock secureTextEntry />\n\n// Ignore all interaction events on this component (touches, scrolls)\n// but still include it in the tree snapshot.\n<ScrollView echoPostIgnore>...</ScrollView>\n```\n\n> **TypeScript:** The `echoPostMask`, `echoPostUnmask`, `echoPostBlock`, and `echoPostIgnore` props are injected into React Native's `ViewProps` type by the SDK. No import is needed.\n\n#### Global text masking\n\n`maskAllTextInputs` (default: `true`) ensures that **all** `TextInput` components are masked, even if they are not inside a masked ancestor and do not have `echoPostMask` set. Set to `false` only if you have manually audited all text fields in your app and want to capture some input values in replays.\n\n```tsx\nreplay: {\n  enabled: true,\n  maskAllTextInputs: false, // ⚠️ Disable only after full privacy audit\n}\n```\n\n### Replay configuration options\n\n| Option | Type | Default | Description |\n|---|---|---|---|\n| `enabled` | `boolean` | `false` | Enable or disable session replay capture |\n| `sampleRate` | `number` | `1.0` | Fraction of sessions to record (0.0–1.0). Evaluated once per `start()` call |\n| `maxDurationMs` | `number` | `30000` | Rolling window length in milliseconds. Events older than this are evicted |\n| `maxBufferBytes` | `number` | `1048576` | Maximum ring buffer size in bytes (1 MB). Hard cap: 2 MB |\n| `snapshotIntervalMs` | `number` | `5000` | How often a full tree snapshot is taken. Lower values = more detail, higher CPU cost |\n| `maskAllTextInputs` | `boolean` | `true` | Always mask `TextInput` content before buffering |\n\n### Manual replay control\n\nThe `Swake.replay` namespace lets you start, stop, and inspect the replay engine at runtime:\n\n```tsx\n// Start a new capture window (no-op if already capturing)\nSwake.replay.start();\n\n// Stop capture and discard the buffer (e.g. on logout or sensitive screens)\nSwake.replay.stop();\n\n// Check whether capture is currently active\nif (Swake.replay.isCapturing()) {\n  console.log('Replay is running');\n}\n```\n\n**Common patterns:**\n\n```tsx\n// Stop replay during payment flow, restart after\nfunction PaymentScreen() {\n  useEffect(() => {\n    Swake.replay.stop();\n    return () => Swake.replay.start();\n  }, []);\n  // ...\n}\n\n// Restart after user logs in (picks up new identity)\nasync function handleLogin(user) {\n  await Swake.identify({ userId: user.id });\n  Swake.replay.start();\n}\n```\n\n> **Automatic attach:** You do not need to call any replay method when submitting a bug report. The SDK calls `attachToSubmission()` automatically when `submitFeedback({ type: 'bug' })` is called and the replay engine is active.\n\n### Event schema\n\nThe replay file is a JSON object stored as `replays/{project_id}/{id}.json`:\n\n```ts\ninterface ReplayFile {\n  version: 1;\n  sdkVersion: string;\n  deviceWidth: number;   // logical pixels\n  deviceHeight: number;  // logical pixels\n  platform: 'ios' | 'android';\n  events: ReplayEvent[];\n}\n```\n\nEach event has this base shape:\n\n```ts\ninterface ReplayEvent {\n  type: EventType;\n  ts: number;   // milliseconds from replay start\n  data: unknown; // type-specific payload (see below)\n}\n```\n\nEvent types and their `data` payloads:\n\n| `type` | `data` shape | Description |\n|---|---|---|\n| `tree_snapshot` | `{ root: TreeNode }` | Full component tree at this point in time |\n| `tree_mutation` | `{ added: TreeNode[], removed: number[], updated: Array<{id, props}> }` | Incremental diff since last snapshot |\n| `touch` | `{ action: 'tap'\\|'swipe_start'\\|'swipe_end', x: number, y: number }` | Touch interaction |\n| `scroll` | `{ x: number, y: number, targetId: number }` | Scroll position |\n| `navigation` | `{ from: string\\|null, to: string }` | Screen transition |\n| `text_change` | `{ targetId: number }` | Text field changed (content always masked) |\n| `keyboard` | `{ action: 'show'\\|'hide' }` | Keyboard visibility |\n| `network` | `{ method: string, url: string, status?: number }` | HTTP request (bodies excluded) |\n\n### Performance budget\n\nThe replay engine is designed to have minimal impact on frame rate and memory:\n\n| Metric | Budget |\n|---|---|\n| Ring buffer memory | ≤ 1 MB (configurable, hard cap 2 MB) |\n| Snapshot CPU (per interval) | < 5 ms on a mid-range device |\n| Upload size | Typically 20–150 KB per 30 s session (after gzip) |\n| Event processing latency | < 1 ms per event (privacy engine + buffer write) |\n\nThe tree serializer caps output at **200 nodes** and **6 levels deep**. If your component tree is deeper, the serializer stops at the limit and marks the truncated nodes.\n\nThe snapshot interval defaults to **5 seconds**. Increasing it to 10 s halves snapshot CPU cost at the expense of replay fidelity. Setting it below 2 s is not recommended.\n\n### Plan limits (replay)\n\n| Plan | Replays per month |\n|---|---|\n| Free | 0 (disabled) |\n| Pro | 100 |\n| Business | 1,000 |\n| Enterprise | Unlimited |\n\nWhen the monthly limit is reached, `submitFeedback()` will still succeed — only the replay upload is silently skipped. No error is thrown.\n\n---\n\n## Feedback history widget\n\nThe `<FeedbackHistory>` component renders the current user's past submissions in a scrollable list with pull-to-refresh. Requires `identify()` to have been called first.\n\n```tsx\nimport { FeedbackHistory } from '@ahmadqarshi/react-native-swake';\n\n// Standalone (embed anywhere in your UI)\n<FeedbackHistory />\n\n// With options\n<FeedbackHistory\n  style={{ flex: 1 }}\n  emptyStateText=\"Nothing submitted yet\"\n  theme={{ colorScheme: 'dark' }}           // SwakeTheme — per-call overrides\n  onItemPress={(item) => {\n    // Custom handler — default opens the detail bottom sheet\n    console.log('Tapped:', item.id, item.title);\n  }}\n/>\n```\n\n**Each row shows:**\n- Type icon (bug / feature / question / other) via `lucide-react-native`\n- Title (truncated to 2 lines)\n- Color-coded status badge: Open · In Progress · Resolved (with a check icon) · Closed\n- Short date (e.g. \"Mar 20\")\n\n**Behavior:**\n- Data is fetched on mount and cached for the session.\n- Pull-to-refresh re-fetches from the API.\n- If `identify()` has not been called, shows \"Identify to see your feedback\" instead of the list.\n\n**Programmatic access:**\n\n```tsx\nconst { submissions, hasMore, cursor } = await Swake.getHistory({ limit: 20 });\n```\n\n---\n\n## Customizable trigger button\n\nThe SDK can render a floating action button (FAB) that opens the feedback form. Configure it in `Swake.init()`:\n\n```tsx\nSwake.init({\n  apiKey: '...',\n  trigger: {\n    position: 'bottom-right',     // 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | { x, y }\n    icon: 'chat',                  // 'chat' | 'feedback' | 'bug' | 'idea' | { uri: 'https://...' }\n    label: 'Give Feedback',        // Optional — renders an extended FAB with text\n    hidden: false,                  // true = hide FAB; call Swake.open() manually\n    style: {\n      backgroundColor: '#6366F1',  // Button background (default: primaryColor)\n      size: 56,                     // FAB diameter in dp (default: 56)\n      borderRadius: 28,             // Corner radius (default: half of size = circle)\n      shadow: true,                 // Drop shadow (default: true)\n    },\n  },\n});\n```\n\nThe trigger FAB is rendered inside `<SwakeProvider>` and positioned absolutely over the entire screen. It animates in with a spring scale + fade on first mount (300ms).\n\n**Tap** → opens the feedback type selector\n**Long press** → opens the bug reporter directly\n\n### Trigger position\n\n| Value | Placement |\n|---|---|\n| `'bottom-right'` (default) | `bottom: 24, right: 24` |\n| `'bottom-left'` | `bottom: 24, left: 24` |\n| `'top-right'` | `top: 24 + safeAreaTop, right: 24` |\n| `'top-left'` | `top: 24 + safeAreaTop, left: 24` |\n| `{ x: number; y: number }` | Absolute `left: x, top: y` |\n\nSafe area insets are read from `react-native-safe-area-context` when available, or fall back to platform defaults (iOS: 44pt, Android: 24dp).\n\n### Trigger style\n\n```tsx\ntrigger: {\n  style: {\n    backgroundColor: '#10B981', // any CSS hex color\n    size: 64,                    // larger FAB\n    borderRadius: 16,            // rounded-square instead of circle\n    shadow: false,               // flat\n  },\n}\n```\n\n### Built-in icons\n\n| Value | Icon (lucide) |\n|---|---|\n| `'chat'` (default) | `MessageCircle` |\n| `'feedback'` | `Megaphone` |\n| `'bug'` | `Bug` |\n| `'idea'` | `Lightbulb` |\n\nRendered with `lucide-react-native` (a required peer dependency).\n\nCustom icon via URI:\n```tsx\ntrigger: { icon: { uri: 'https://example.com/my-icon.png' } }\n```\n\n### Extended FAB with label\n\nSetting `label` renders a wider button with text:\n\n```tsx\ntrigger: {\n  icon: 'chat',\n  label: 'Give Feedback',\n}\n// Renders: [ 💬  Give Feedback ]\n```\n\n### Hidden trigger & programmatic control\n\nSet `hidden: true` to disable the FAB entirely and control the entry point from your own UI:\n\n```tsx\nSwake.init({ apiKey: '...', trigger: { hidden: true } });\n\n// In your own button or gesture handler:\nSwake.open();        // Opens feedback type selector\nSwake.open('bug');   // Opens bug reporter directly\nSwake.close();       // Closes any open Swake UI\n\n// Dynamic show/hide at runtime (e.g. hide during onboarding):\nSwake.setTriggerVisible(false);\nSwake.setTriggerVisible(true);\n```\n\n---\n\n## Plan limits\n\nSwake workspaces have plan-based usage limits. The SDK handles limit responses from the API gracefully so your end-users never see raw error messages.\n\n### Plan tiers\n\n| Limit | Free | Pro | Business | Enterprise |\n|---|---|---|---|---|\n| Projects | 1 | 3 | 10 | Unlimited |\n| Feedback / month | 50 | 1,000 | 10,000 | Unlimited |\n| Tracked users / month | 100 | 2,000 | 20,000 | Unlimited |\n| Team members | 1 | 5 | 15 | Unlimited |\n| File storage | 100 MB | 2 GB | 10 GB | Unlimited |\n| API keys / project | 2 | 5 | 10 | 20 |\n| Voting boards | 1 | 3 | 10 | Unlimited |\n| Session replays / month | 0 | 100 | 1,000 | Unlimited |\n| Data retention | 90 days | 1 year | 2 years | Unlimited |\n\n> Workspace owners can upgrade, downgrade, or manage payment methods at any time via the Swake portal's billing page (`/settings/billing`). Plan changes take effect immediately — the SDK picks up the new limits on the very next API request.\n\n> **Payment failure grace period:** If a payment fails, the workspace remains on its paid plan for 7 days while the owner resolves the issue. After 7 days without a successful payment, the workspace is automatically downgraded to the Free tier. All data is preserved.\n\n### How limits are handled\n\n| Scenario | SDK behaviour |\n|---|---|\n| `feedback_per_month` limit hit | Form shows a friendly \"we're unable to accept new feedback right now\" message. No plan or upgrade language is shown to the user. |\n| `tracked_users` limit hit | `identify()` degrades silently — the user is still identified locally (submissions still work). |\n| `file_storage_bytes` limit hit on attachment upload | The submission is created successfully; the attachment is skipped. The success screen shows a one-line note. An `AttachmentLimitError` is thrown from `submitFeedback()` for programmatic callers. |\n| Any limit hit in the offline queue | The queue item is dropped immediately (never retried). |\n\n### Configuring limit behaviour\n\nControl how the SDK reacts when a limit is reached via `onLimitReachedMode` in `init()`:\n\n| Mode | Description |\n|---|---|\n| `'default_ui'` | Built-in friendly message shown in the form UI. **Default.** |\n| `'silent'` | No UI, no callback, no warnings. |\n| `'callback_only'` | No UI; only the `onLimitReached` callback fires. |\n\n```tsx\nSwake.init({\n  apiKey: 'ep_live_...',\n  onLimitReachedMode: 'callback_only',\n  onLimitReached: (event) => {\n    // event.limitKey: 'feedback_per_month' | 'tracked_users' | 'file_storage_bytes' | ...\n    // event.message: human-readable message from the API\n    Analytics.track('plan_limit_reached', { limitKey: event.limitKey });\n  },\n});\n```\n\nYou can also register or replace the callback at runtime (e.g. after the user navigates to a specific screen):\n\n```tsx\nSwake.onLimitReached((event) => {\n  console.warn('Swake limit reached:', event.limitKey);\n});\n```\n\n### Handling errors programmatically\n\nWhen calling `submitFeedback()` directly (not via the built-in form), catch `PlanLimitError` and `AttachmentLimitError`:\n\n```tsx\nimport Swake, { PlanLimitError, AttachmentLimitError } from '@ahmadqarshi/react-native-swake';\n\ntry {\n  const submission = await Swake.submitFeedback({\n    type: 'bug',\n    title: 'Crash on startup',\n    attachments: [{ uri, filename, mimeType }],\n  });\n  console.log('Submitted:', submission.id);\n} catch (err) {\n  if (err instanceof AttachmentLimitError) {\n    // Submission was created — only the attachment was skipped\n    console.log('Submitted (no attachment):', err.submission.id);\n    showToast('Your report was sent. The screenshot could not be uploaded.');\n  } else if (err instanceof PlanLimitError) {\n    // Feedback limit reached — no submission was created\n    console.warn('Plan limit:', err.limitKey, err.message);\n  } else {\n    throw err;\n  }\n}\n```\n\n#### `PlanLimitError`\n\nExtends `SwakeError`. Thrown when the API returns a `plan_limit_exceeded` error.\n\n| P","readmeFilename":"README.md"}