@lorikeetai/react-native-sdk 2.0.0-alpha.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -20
- package/dist/index.cjs +14 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +427 -14
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +427 -14
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/bridge.ts","../src/encode.ts","../src/client.ts","../src/errors.ts","../src/events.ts","../src/lifecycle.ts","../src/links.ts","../src/message-circle-plus-icon.tsx","../src/widget.tsx"],"sourcesContent":["/**\n * The conversation the live WebView is actually in, which is not necessarily the\n * one the host passed as `ticketId`: a mount without one creates a ticket, and\n * only the bridge knows its id.\n */\nexport type ObservedConversation = {\n /** The credentials this conversation was created under. */\n identity: string\n /**\n * The ticket the mount was aiming for when this was observed, or `undefined`\n * for a mount that created its own. A host switching to another conversation\n * changes it, which retires the observation: without that, a recovery in the\n * window before the new page reports itself would navigate back to the old\n * ticket and undo the switch.\n */\n intendedTicketId?: string\n session?: string\n ticketId?: string\n}\n\n/**\n * The public key, base URL and token are one unit, and a conversation belongs to\n * the credentials that created it. Stamping an observation with them is what\n * stops a remount resuming one environment's ticket against another's key.\n */\nexport const conversationIdentity = ({\n authToken,\n baseUrl,\n publicKey,\n}: {\n authToken: string\n baseUrl?: string\n publicKey: string\n}): string => `${publicKey} ${baseUrl ?? ''} ${authToken}`\n\n/**\n * A remount has to resume the conversation it was in, or the recovery abandons\n * the ticket it was meant to rescue. Only usable when the credentials still\n * match the ones that produced it.\n */\nexport const resumableConversation = (\n observed: ObservedConversation | null,\n identity: string,\n intendedTicketId: string | undefined,\n): { session?: string; ticketId: string } | null => {\n if (\n observed === null ||\n observed.identity !== identity ||\n observed.intendedTicketId !== intendedTicketId ||\n observed.ticketId === undefined\n ) {\n return null\n }\n return { session: observed.session, ticketId: observed.ticketId }\n}\n","const BASE64_ALPHABET =\n 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n\n/**\n * Base64 of a string's UTF-8 bytes.\n *\n * The chat page decodes this as UTF-8 (`Buffer.from(value, 'base64')`), so\n * UTF-8 is the contract. The global `btoa` cannot meet it: it accepts Latin-1\n * only, so a typographic apostrophe or an emoji in a title throws\n * \"Found invalid character when converting to base64\" and takes the render with\n * it. Hermes also had no `btoa` at all before React Native 0.74, which is\n * inside this package's supported peer range.\n */\nexport function base64EncodeUtf8(value: string): string {\n // encodeURIComponent is UTF-8 by specification, so percent-decoding its\n // output one byte at a time yields the UTF-8 encoding without needing\n // TextEncoder, which has the same React Native availability problem as btoa.\n const bytes = encodeURIComponent(value).replace(\n /%([0-9A-F]{2})/g,\n (_, hex: string) => String.fromCharCode(parseInt(hex, 16)),\n )\n\n let encoded = ''\n for (let index = 0; index < bytes.length; index += 3) {\n const first = bytes.charCodeAt(index)\n const second =\n index + 1 < bytes.length ? bytes.charCodeAt(index + 1) : undefined\n const third =\n index + 2 < bytes.length ? bytes.charCodeAt(index + 2) : undefined\n\n encoded += BASE64_ALPHABET[first >> 2]\n encoded += BASE64_ALPHABET[((first & 0x03) << 4) | ((second ?? 0) >> 4)]\n encoded +=\n second === undefined\n ? '='\n : BASE64_ALPHABET[((second & 0x0f) << 2) | ((third ?? 0) >> 6)]\n encoded += third === undefined ? '=' : BASE64_ALPHABET[third & 0x3f]\n }\n\n return encoded\n}\n","import type { ConversationMetadata } from './conversation-metadata'\nimport type { Customisations } from './customisations'\nimport { base64EncodeUtf8 } from './encode'\nimport type { LorikeetInitOptions } from './types'\n\nexport class LorikeetClient {\n baseUrl: string = 'https://chat.lorikeetcx.ai' // Fallback URL\n customisations: Customisations | null = null\n metadata: ConversationMetadata | null = null\n publicKey: string\n\n constructor({\n customisations,\n api_base,\n metadata,\n public_key,\n }: LorikeetInitOptions) {\n if (api_base) {\n this.baseUrl = api_base\n }\n this.publicKey = public_key\n\n if (customisations) {\n this.customisations = customisations\n }\n\n if (metadata) {\n this.metadata = metadata\n }\n }\n\n getWebviewUrl = ({\n customer_email,\n customer_name,\n token,\n ticket_id,\n session,\n }: {\n customer_email?: string\n customer_name?: string\n session?: string\n ticket_id?: string\n token: string\n }) => {\n const url = new URL(this.baseUrl)\n\n url.pathname = ticket_id\n ? `/tickets/${this.publicKey}/${ticket_id}`\n : '/widget'\n url.searchParams.append('public_key', this.publicKey)\n\n if (customer_email) {\n url.searchParams.append('customer_email', customer_email)\n }\n if (customer_name) {\n url.searchParams.append('customer_name', customer_name)\n }\n\n url.searchParams.append('token', token)\n\n // The session authorises exactly the ticket it was issued for, so it only\n // means anything on the resume path. Without it the ticket route cannot\n // authorise a fresh WebView and answers CUSTOMER_ID_MISSING.\n if (ticket_id && session) {\n url.searchParams.append('session', session)\n }\n\n if (this.customisations) {\n url.searchParams.append(\n 'customisations',\n base64EncodeUtf8(JSON.stringify(this.customisations)),\n )\n }\n\n // Plain JSON, unlike customisations. The routes read it with\n // `searchParams.get('metadata')` and parse it directly.\n if (this.metadata) {\n url.searchParams.append('metadata', JSON.stringify(this.metadata))\n }\n\n return url.toString()\n }\n\n /**\n * The page that clears this key's credentials from the WebView: its cookies,\n * server-side, and the storage the frame keeps. Navigating a WebView here is\n * the only way to reach either, so a host that only drops its own copy of\n * `ticketId` and `session` leaves the next person on the device able to\n * reopen the conversation.\n *\n * The page confirms with a `loggedOut` message, which the SDK reports as\n * `logged_out`. The load event does not: an error page fires that too.\n */\n getLogoutUrl = () => {\n const url = new URL(this.baseUrl)\n url.pathname = '/logout'\n url.searchParams.append('public_key', this.publicKey)\n return url.toString()\n }\n}\n","/**\n * Codes the chat page posts to the host as `{ type: 'error', error: CODE }`.\n * Mirrored here because the page publishes them nowhere else, so a host would\n * otherwise be matching on strings it discovered by observation.\n */\nexport const LORIKEET_ERROR_CODES = [\n 'MISSING_TOKEN',\n 'CORRUPT_TOKEN_DATA',\n 'DECRYPT_FAILED',\n 'EXPIRED_TOKEN',\n 'MALFORMED_TOKEN',\n 'MISSING_PUBLIC_KEY',\n 'UNKNOWN_PUBLIC_KEY',\n 'CUSTOMER_ID_CONFLICT',\n 'CUSTOMER_ID_MISSING',\n 'FATAL_ERROR',\n 'INVALID_SESSION',\n 'DOMAIN_NOT_ALLOWED',\n 'TICKET_NOT_FOUND',\n] as const\n\nexport type LorikeetErrorCode = (typeof LORIKEET_ERROR_CODES)[number]\n\n/**\n * The codes that mean the stored conversation cannot be reopened, as opposed to\n * the trusted token being bad or the integration being misconfigured. Only\n * these are worth abandoning a resume for: a token error fails again with the\n * same token, and a configuration error fails again regardless.\n */\nconst UNUSABLE_CONVERSATION_CODES: readonly LorikeetErrorCode[] = [\n 'CUSTOMER_ID_MISSING',\n 'INVALID_SESSION',\n 'TICKET_NOT_FOUND',\n]\n\n/** Whether an error code means the stored conversation cannot be reopened. */\nexport function isUnusableConversationCode(code: string): boolean {\n return UNUSABLE_CONVERSATION_CODES.includes(code as LorikeetErrorCode)\n}\n\n// oxlint-disable-next-line @lorikeetai/no-unknown-parameters -- Hosts pass raw JSON posted by the chat page, which may be any message.\nexport function isUnusableConversation(message: unknown): boolean {\n if (typeof message !== 'object' || message === null) {\n return false\n }\n const { type, error } = message as { error?: unknown; type?: unknown }\n return type === 'error' && typeof error === 'string'\n ? isUnusableConversationCode(error)\n : false\n}\n","/**\n * What the page reports, as events rather than as raw bridge messages.\n *\n * Names follow the web SDK's (`LorikeetEvent` in `@lorikeetai/web-sdk/types`)\n * wherever it has one, because a host that integrates both should not have to\n * learn two vocabularies. The rest cover what the page tells a host that owns\n * its own layout and storage, which on web the SDK handles internally and never\n * surfaces: the close handshake, the persistence messages, the logout\n * confirmation, and error codes. `logout_failed` is the one the SDK reports\n * itself, for a page that said nothing when it had to.\n *\n * `token_expiry` is deliberately absent. The credential model it belongs to is\n * being rewritten, and an event is the hardest thing to take back. Nothing\n * carrying a credential is translated at all, for the same reason.\n */\nexport type LorikeetWidgetEvent =\n /** The page is up and the conversation is usable. */\n | { type: 'ready' }\n /**\n * A ticket that did not exist before this page loaded. Once per ticket, and\n * never for a resume, matching the web SDK.\n */\n | { type: 'ticket_created'; ticketId: string }\n /** The customer sent something. */\n | { type: 'customer_message' }\n /**\n * The agent sent something. `markUnread` is the page asking for an unread\n * indicator, which it only does where its live chat experience is on; the web\n * SDK's launcher dot is driven by it, and it badges only while the widget is\n * out of sight.\n *\n * Absent from a page older than the release that added it, which is not the\n * same as `false`.\n */\n | { type: 'agent_message'; markUnread?: boolean }\n /**\n * The page closed itself, and why. `ended_by_customer` is followed by a\n * `new_chat`, seconds later, so a host that tears down here never sees the\n * rest of the handshake. `minimised` has nothing behind it: the conversation\n * is intact and hiding it is enough.\n *\n * `reason` is absent from a page older than the release that added it.\n */\n | { type: 'close'; reason?: 'ended_by_customer' | 'minimised' }\n /**\n * The conversation ended without the customer ending it: an agent closed it,\n * or it timed out. Nothing follows, and nothing is asking the host to hide\n * anything. Left unhandled, this is the customer stranded on a conversation\n * that can no longer be used.\n */\n | { type: 'conversation_ended' }\n /**\n * The page asking for a replacement conversation. It cannot navigate itself,\n * so acting on this is the host's half of End chat.\n */\n | { type: 'new_chat' }\n /**\n * Store this against the customer, alongside the session. A resume needs both:\n * the ticket alone fails with `CUSTOMER_ID_MISSING`.\n */\n | { type: 'persist_ticket_id'; ticketId: string }\n /** The other half of the stored pair. Arrives separately and in no order. */\n | { type: 'persist_session'; session: string }\n /**\n * `/logout` cleared the frame: the chat's cookies for this key, server-side,\n * and the storage the page kept. Reached by navigating the WebView to\n * `LorikeetClient.getLogoutUrl()`, and the only confirmation that the\n * clearing happened. The page's load event is not: an error page fires that\n * too.\n *\n * `persistAnonymousSessionToken` is deliberately not translated. It carries a\n * 365-day identity credential, this SDK offers no way to use one, and the web\n * SDK keeps the same message inside the frame rather than handing it to its\n * host. A typed event would be a published commitment to storing it, days\n * before the hand-off protocol is meant to stop putting it in a URL at all.\n */\n | { type: 'logged_out' }\n /**\n * The SDK's own report, not the page's: `/logout` did not confirm inside\n * `LOGOUT_CONFIRMATION_TIMEOUT_MS`, so the previous customer may still be\n * signed in on this device.\n *\n * The widget stays unavailable rather than starting the next customer's chat,\n * because the alternative is handing them a device the last one's credentials\n * still open. Call `logout()` again to retry.\n */\n | { type: 'logout_failed' }\n /** A page-level failure. `LORIKEET_ERROR_CODES` lists the codes. */\n | { type: 'error'; error: string }\n /**\n * The preload page finished warming, and what the server said about it.\n *\n * Unreachable from React Native, and published in error. The preload page\n * posts straight to `window.parent` behind a `window.parent !== window`\n * guard, which is false in a WebView, so it never takes the native bridge the\n * page's other messages go through. Kept because removing a published union\n * member is a breaking change; retire it when 2.x leaves alpha, or when the\n * page learns to post it natively.\n */\n | {\n type: 'preload_complete'\n clientStart: boolean\n lazyTicketCreationEnabled: boolean\n }\n\n/** The names, spelled out rather than derived, so callers import a real type. */\nexport type LorikeetWidgetEventType =\n | 'agent_message'\n | 'close'\n | 'conversation_ended'\n | 'customer_message'\n | 'error'\n | 'logged_out'\n | 'logout_failed'\n | 'new_chat'\n | 'persist_session'\n | 'persist_ticket_id'\n | 'preload_complete'\n | 'ready'\n | 'ticket_created'\n\n/** What `JSON.parse` can produce from a bridge message. */\nexport type BridgeMessageJson =\n | string\n | number\n | boolean\n | null\n | BridgeMessageJson[]\n | { [key: string]: BridgeMessageJson }\n\nconst asRecord = (message: BridgeMessageJson | undefined) =>\n typeof message === 'object' && message !== null && !Array.isArray(message)\n ? message\n : null\n\nconst asNonEmptyString = (value: BridgeMessageJson | undefined) =>\n typeof value === 'string' && value !== '' ? value : null\n\n/**\n * The event a bridge message carries, or `null` for one that carries none.\n *\n * `ticket_created` is not derived here: telling a fresh ticket from a resumed\n * one needs the URL the page was loaded with, which only the component holds.\n */\nexport const eventFromMessage = (\n message: BridgeMessageJson | undefined,\n): LorikeetWidgetEvent | null => {\n const record = asRecord(message)\n if (record === null || typeof record.type !== 'string') {\n return null\n }\n switch (record.type) {\n case 'ready':\n return { type: 'ready' }\n case 'customerMessage':\n return { type: 'customer_message' }\n case 'agentMessage':\n // Reported only when the page said so, so a host can tell \"not worth a\n // badge\" from a page too old to have an opinion.\n return typeof record.markUnread === 'boolean'\n ? { type: 'agent_message', markUnread: record.markUnread }\n : { type: 'agent_message' }\n case 'closeChat': {\n const reason = asNonEmptyString(record.reason)\n return reason === 'ended_by_customer' || reason === 'minimised'\n ? { type: 'close', reason }\n : { type: 'close' }\n }\n case 'conversationEnded':\n return { type: 'conversation_ended' }\n case 'startNewChat':\n return { type: 'new_chat' }\n case 'persistLorikeetTicketId': {\n const ticketId = asNonEmptyString(record.ticketId)\n return ticketId === null ? null : { type: 'persist_ticket_id', ticketId }\n }\n case 'persistLorikeetSession': {\n const session = asNonEmptyString(record.session)\n return session === null ? null : { type: 'persist_session', session }\n }\n case 'loggedOut':\n return { type: 'logged_out' }\n case 'error': {\n const error = asNonEmptyString(record.error)\n return error === null ? null : { type: 'error', error }\n }\n case 'preloadComplete':\n return {\n type: 'preload_complete',\n clientStart: record.clientStart === true,\n lazyTicketCreationEnabled: record.lazyTicketCreationEnabled === true,\n }\n default:\n return null\n }\n}\n\n/**\n * The ticket a URL is trying to resume, or `null` for one that creates its own.\n *\n * Read from the URL rather than from any state, so it holds for a host that\n * built the URL itself. An unreadable URL reports nothing, which at worst\n * announces a resumed ticket as created once.\n */\nexport const resumedTicketIdFromUri = (uri: string): string | null => {\n let pathname: string\n try {\n pathname = new URL(uri).pathname\n } catch {\n return null\n }\n // `/tickets/:publicKey/:ticketId`, which is what `getWebviewUrl` builds and\n // what the chat app routes.\n const segments = pathname.split('/').filter((part) => part !== '')\n if (segments.length < 3 || segments[0] !== 'tickets') {\n return null\n }\n return segments[2] ?? null\n}\n","/**\n * The page gives its message stream ten attempts with exponential backoff\n * capped at 30s (`message-stream-utils.tsx`), so the budget runs out roughly\n * 151s after the stream drops, or ~196s once jitter is counted. It then sits at\n * `failed` with nothing to reset it, so a customer who comes back to a\n * backgrounded app is looking at a stream that will never reconnect.\n *\n * Three minutes sits inside that band. Shorter would remount while the page's\n * own retries could still win, which costs the customer their draft message for\n * nothing; much longer leaves a dead stream on screen.\n */\nexport const BACKGROUND_RELOAD_THRESHOLD_MS = 180_000\n\n/**\n * How long `/logout` gets to confirm it cleared the frame before the host is\n * told it did not. Matches the web SDK's own budget, and like it, a logout that\n * misses the deadline leaves the widget unavailable rather than letting the next\n * customer start a chat on a device the previous one's credentials still open.\n */\nexport const LOGOUT_CONFIRMATION_TIMEOUT_MS = 10_000\n\n/**\n * Elapsed time is the only signal available: the bridge reports `ready`,\n * `error` and the persistence messages, but never stream health, so the SDK\n * cannot ask whether the connection actually survived.\n */\nexport const shouldReloadAfterBackground = (\n backgroundedAt: number | null,\n now: number,\n thresholdMs: number = BACKGROUND_RELOAD_THRESHOLD_MS,\n): boolean => {\n if (backgroundedAt === null) {\n return false\n }\n // A clock that moved backwards says nothing about how long we were away.\n const elapsed = now - backgroundedAt\n if (elapsed < 0) {\n return false\n }\n return elapsed >= thresholdMs\n}\n","/**\n * Schemes the WebView resolves for itself. None of them is a page the customer\n * asked to leave the app for, and the OS cannot open them anyway.\n */\nconst WEBVIEW_OWNED_SCHEMES = new Set([\n 'about:',\n 'blob:',\n 'data:',\n 'javascript:',\n])\n\nconst parseUrl = (value: string): URL | null => {\n try {\n return new URL(value)\n } catch {\n return null\n }\n}\n\n/**\n * Whether the OS could be asked to open this at all. `window.open()` with no\n * argument gives `about:blank`, and a scheme the WebView resolves for itself is\n * not something the OS can place.\n */\nexport const canOpenOutsideApp = (url: string): boolean => {\n const target = parseUrl(url)\n return target !== null && !WEBVIEW_OWNED_SCHEMES.has(target.protocol)\n}\n\n/**\n * Whether a navigation belongs inside the widget.\n *\n * An origin comparison rather than the substring match this replaces. `baseUrl`\n * appearing anywhere in a URL counted as internal, so\n * `https://evil.example/?next=https://chat.lorikeetcx.ai` loaded in place of the\n * conversation instead of opening in the browser. Comparing origins rather than\n * matching a prefix also means a port, a trailing slash and a different path all\n * still resolve to the same widget.\n */\nexport const isInternalNavigation = (url: string, baseUrl: string): boolean => {\n const base = parseUrl(baseUrl)\n const target = parseUrl(url)\n // With either side unparseable there is no basis for sending the customer out\n // of the app, so the navigation stays with the WebView. Failing the other way\n // would hand the chat's own URL, and the session in it, to the system browser.\n if (base === null || target === null) {\n return true\n }\n if (WEBVIEW_OWNED_SCHEMES.has(target.protocol)) {\n return true\n }\n return target.origin === base.origin\n}\n","import Svg, { Line, Path } from 'react-native-svg'\n\ninterface MessageCirclePlusIconProps {\n color?: string\n size?: number\n strokeWidth?: number\n}\n\nexport const MessageCirclePlusIcon = ({\n size = 24,\n color = 'currentColor',\n strokeWidth = 2,\n}: MessageCirclePlusIconProps) => {\n return (\n <Svg fill=\"none\" height={size} viewBox=\"0 0 24 24\" width={size}>\n <Path\n d=\"M7.9 20A9 9 0 1 0 4 16.1L2 22Z\"\n stroke={color}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={strokeWidth}\n />\n <Line\n stroke={color}\n strokeLinecap=\"round\"\n strokeWidth={strokeWidth}\n x1=\"12\"\n x2=\"12\"\n y1=\"8\"\n y2=\"16\"\n />\n <Line\n stroke={color}\n strokeLinecap=\"round\"\n strokeWidth={strokeWidth}\n x1=\"8\"\n x2=\"16\"\n y1=\"12\"\n y2=\"12\"\n />\n </Svg>\n )\n}\n","import {\n forwardRef,\n useCallback,\n useEffect,\n useImperativeHandle,\n useRef,\n useState,\n} from 'react'\n\nimport {\n AppState,\n Linking,\n StyleSheet,\n Text,\n TouchableOpacity,\n View,\n} from 'react-native'\nimport { SafeAreaView } from 'react-native-safe-area-context'\nimport { WebView } from 'react-native-webview'\nimport type { WebViewMessageEvent, WebViewProps } from 'react-native-webview'\n\nimport { conversationIdentity, resumableConversation } from './bridge'\nimport type { ObservedConversation } from './bridge'\nimport { LorikeetClient } from './client'\nimport { isUnusableConversationCode } from './errors'\nimport { eventFromMessage, resumedTicketIdFromUri } from './events'\nimport type { BridgeMessageJson, LorikeetWidgetEvent } from './events'\nimport {\n LOGOUT_CONFIRMATION_TIMEOUT_MS,\n shouldReloadAfterBackground,\n} from './lifecycle'\nimport { canOpenOutsideApp, isInternalNavigation } from './links'\nimport { MessageCirclePlusIcon } from './message-circle-plus-icon'\nimport type {\n LorikeetWidgetHandle,\n LorikeetWidgetHeaderProps,\n LorikeetWidgetViewProps,\n LorikeetWidgetWebViewProps,\n} from './types'\n\nconst stylesheet = StyleSheet.create({\n root: { flex: 1, backgroundColor: '#fff' },\n container: {\n flex: 1,\n borderRadius: 8,\n overflow: 'hidden',\n shadowColor: '#000',\n shadowOffset: { width: 0, height: 2 },\n shadowOpacity: 0.1,\n shadowRadius: 4,\n elevation: 3,\n },\n header: {\n padding: 16,\n display: 'flex',\n flexDirection: 'row',\n justifyContent: 'space-between',\n },\n headerIcon: {\n width: 24,\n },\n headerText: {\n textAlign: 'center',\n fontSize: 16,\n },\n closedControl: {\n padding: 16,\n },\n webView: {\n flex: 1,\n },\n // A closed page is out of sight but still laid out, still full size and still\n // in the view hierarchy.\n //\n // `display: 'none'` is the obvious way to hide it and is the one that broke:\n // on iOS the page stopped running, and the page is what posts the\n // `startNewChat` this is waiting for. Why it stopped is not established. The\n // hiding alone does not explain it: hiding a WKWebView, by zero size or by\n // `hidden` on it or on its parent, leaves the page running in isolation, so\n // something further up is implicated, with Fabric recycling the component view\n // the leading candidate, since react-native-webview destroys the WebView on\n // recycle. Whatever the cause, the requirement is the same and is worth\n // stating on its own terms: a close must not alter the view structurally,\n // because the page has to keep running for seconds afterwards.\n closed: {\n flex: 1,\n opacity: 0,\n },\n})\n\nexport const LorikeetWidgetWebView = forwardRef<\n WebView,\n LorikeetWidgetWebViewProps\n>(\n (\n { uri, baseUrl = 'https://chat.lorikeetcx.ai', webviewProps = {}, onEvent },\n ref,\n ) => {\n const isInternal = (url: string) => isInternalNavigation(url, baseUrl)\n // Every ticket this component already knows about, whether it announced it\n // or the URL asked to resume it. `ticket_created` fires once per ticket and\n // never for a resume, and this is what both of those come down to.\n //\n // Deliberately not reset when `uri` changes. A page that has been navigated\n // away from can still post, and its late report would otherwise be read\n // against the URL that replaced it: the ticket it names is neither the new\n // URL's resume nor yet announced, so it would be announced a second time.\n // Remembering the ticket rather than the page is what closes that without\n // giving this component a generation to track, which belongs to whoever\n // owns the URL.\n const accounted = useRef<Set<string>>(new Set())\n const seenUri = useRef<string | null>(null)\n if (seenUri.current !== uri) {\n seenUri.current = uri\n const resumed = resumedTicketIdFromUri(uri)\n if (resumed !== null) {\n accounted.current.add(resumed)\n }\n }\n\n const emit = (event: LorikeetWidgetEvent) => {\n onEvent?.(event)\n if (event.type !== 'persist_ticket_id') {\n return\n }\n if (accounted.current.has(event.ticketId)) {\n return\n }\n accounted.current.add(event.ticketId)\n onEvent?.({ ticketId: event.ticketId, type: 'ticket_created' })\n }\n\n return (\n <WebView\n style={stylesheet.webView}\n {...webviewProps}\n // The one place bridge messages are read, so the drop-in and a composed\n // host are told the same things by the same code. The raw handler still\n // runs afterwards: this is additive, not a replacement.\n onMessage={(event) => {\n let message: BridgeMessageJson | undefined\n try {\n message = JSON.parse(event.nativeEvent.data)\n } catch {\n // Not JSON, so not one of the page's messages. Pass it through.\n }\n const widgetEvent = eventFromMessage(message)\n if (widgetEvent !== null) {\n emit(widgetEvent)\n }\n webviewProps.onMessage?.(event)\n }}\n // A backstop for a navigation that never reached\n // `onShouldStartLoadWithRequest`, whose coverage differs by platform and by\n // how the page triggered the navigation. The two cannot both open the same\n // URL: one that was turned away there never becomes the navigation state.\n onNavigationStateChange={(navState) => {\n if (!isInternal(navState.url)) {\n void Linking.openURL(navState.url)\n }\n webviewProps.onNavigationStateChange?.(navState)\n }}\n // A second window is the one thing the WebView cannot give the page, so\n // without this a `target=\"_blank\"` link silently does nothing. That is not a\n // corner: the chat sets `<base target=\"_blank\">`, so it is every link in\n // every message, and this rather than the handler below is where most of\n // them arrive.\n //\n // Every target goes out, the widget's own origin included. A browser answers\n // `target=\"_blank\"` with a new tab and leaves the iframe where it is, and\n // the system browser is the nearest thing to that on a phone. Loading it in\n // this WebView instead would replace the conversation the customer is in,\n // with nothing to go back with.\n onOpenWindow={(event) => {\n const { targetUrl } = event.nativeEvent\n if (canOpenOutsideApp(targetUrl)) {\n void Linking.openURL(targetUrl)\n }\n webviewProps.onOpenWindow?.(event)\n }}\n // The only handler that can stop a navigation rather than report one that\n // already started, so this is where an external link is turned away.\n onShouldStartLoadWithRequest={(request) => {\n // Asked first, so a host can claim a navigation, such as a deep link it\n // wants to route itself. Declining is a decision, not an absence of one, so\n // the SDK does not then second-guess it by opening the OS as well.\n if (webviewProps.onShouldStartLoadWithRequest?.(request) === false) {\n return false\n }\n // iOS reports subframe navigations here as well. A frame the page\n // embedded is not a link the customer followed, and lifting it out of the\n // page would break the page.\n //\n // Only an explicit `false` earns that exemption. The peer range is the\n // whole of `react-native-webview` 13, which does not report this field\n // uniformly, and reading a missing one as a subframe would exempt every\n // navigation and leave external pages loading inside the widget.\n if (request.isTopFrame === false || isInternal(request.url)) {\n return true\n }\n void Linking.openURL(request.url)\n return false\n }}\n ref={ref}\n source={{\n uri,\n }}\n />\n )\n },\n)\nLorikeetWidgetWebView.displayName = 'LorikeetWidgetWebView'\n\nexport const LorikeetWidgetHeader = ({\n title,\n style,\n icon = <MessageCirclePlusIcon size={16} />,\n onIconPress,\n}: LorikeetWidgetHeaderProps) => {\n return (\n <View style={stylesheet.header}>\n <View style={stylesheet.headerIcon} />\n <Text style={[stylesheet.headerText, style]}>{title}</Text>\n <TouchableOpacity onPress={onIconPress} style={stylesheet.headerIcon}>\n {icon}\n </TouchableOpacity>\n </View>\n )\n}\n\nexport const LorikeetWidgetView = forwardRef<\n LorikeetWidgetHandle,\n LorikeetWidgetViewProps\n>(function LorikeetWidgetView(\n {\n publicKey,\n baseUrl,\n authToken,\n customisations,\n styles,\n onEvent,\n onWebViewMessage,\n ticketId,\n session,\n metadata,\n webviewProps,\n },\n ref,\n) {\n const [lorikeetClient, setLorikeetClient] = useState<LorikeetClient | null>(\n null,\n )\n // The URL and the identity it belongs to move together in one piece of state.\n // Keeping the URL alone would let a render see the previous identity's resume\n // URL, and the WebView would load that authorised conversation before the\n // effect could redirect it.\n const [loaded, setLoaded] = useState<{\n identity: string\n intendedTicketId?: string\n uri: string\n } | null>(null)\n // A counter, not a timestamp: two reloads inside the same millisecond would\n // produce the same key and silently not remount at all.\n const [generation, setGeneration] = useState(0)\n // Read during render so a handler can tell whether the WebView it was\n // installed on is still the one mounted. Identity and intended ticket cannot\n // answer that: starting a new chat replaces a self-created conversation with\n // another that has exactly the same pair.\n const generationRef = useRef(generation)\n generationRef.current = generation\n const remount = useCallback(() => {\n setGeneration((previous) => previous + 1)\n }, [])\n const webViewRef = useRef<WebView>(null)\n // A resume the server refuses is a dead end on a phone. The ticket route\n // redirects an unauthorised resume to /widget without the trusted token,\n // which answers CUSTOMER_ID_MISSING, and the host has no reason to stop\n // handing the same stale ticket back on the next mount. Give up on that\n // conversation once and start a fresh one instead.\n const [refusedTicketId, setRefusedTicketId] = useState<string | null>(null)\n // What the bridge has said about the conversation on screen. A mount without\n // `ticketId` creates a ticket the host may not have stored yet, so without\n // this a remount would load `/widget`, mint another one, and abandon the\n // conversation the remount was meant to rescue. Held in a ref, not state:\n // reading it must not re-render, and it is deliberately not persisted, since\n // surviving a process death is the host's job.\n const observedRef = useRef<ObservedConversation | null>(null)\n // Outside the listener effect deliberately. That effect re-subscribes whenever\n // `reload` changes, which a new client instance does, and a local would reset\n // to null mid-background and skip the recovery on return.\n const backgroundedAtRef = useRef<number | null>(null)\n // The URL now loaded, and what it was built for. Bridge messages are stamped\n // from this rather than the current props: when credentials change, this\n // component re-renders and replaces the message handler while the old page is\n // still alive, and stamping a late message from that page with the new\n // identity would let a recovery reopen the previous customer's conversation\n // under the next customer's credentials. The ticket route authorises a\n // ticket-bound session independently of the trusted token, so that would be a\n // cross-customer read, not merely the wrong conversation.\n const uriRef = useRef<string | null>(null)\n const loadedForRef = useRef<{\n identity: string\n intendedTicketId?: string\n }>({ identity: '', intendedTicketId: undefined })\n const identity = conversationIdentity({ authToken, baseUrl, publicKey })\n // The ticket this mount will try to resume, if any.\n const resumingTicketId =\n ticketId !== undefined && ticketId !== refusedTicketId\n ? ticketId\n : undefined\n // The ticket the URL now loaded is actually trying to resume, which is not\n // always the prop: a remount resumes what the bridge reported. Refusal has to\n // key off this, or a bridge-derived resume the server rejects leaves the\n // WebView on the error page with every later recovery retrying the same dead\n // ticket.\n const [attemptedTicketId, setAttemptedTicketId] = useState<\n string | undefined\n >(undefined)\n // Whether the page on screen closed itself. End chat posts `closeChat` and\n // then `startNewChat`, so a page torn down on the first never delivers the\n // second: the host keeps the ended ticket and reopens it read-only. Web has\n // the same pair of messages and hides the iframe rather than unloading it.\n //\n // The gap between the two is not a moment. The page posts the second from a\n // later effect that waits on the end and CSAT requests settling, so whatever\n // this does to the page has to leave it running for seconds afterwards. That\n // is what decides how the close hides; see `stylesheet.closed`.\n //\n // A minimise is not an end. It means put this away for a moment, so it keeps\n // the conversation and offers a control that brings it back, where an end\n // offers the new-chat control instead. An absent reason reads as an end: a\n // page that does not report one sends a bare close, and reading those as a\n // minimise would change what every host on such a page already sees.\n //\n // Hiding moves neither `generation` nor `loaded`, so a hidden WebView is still\n // the current page: its bridge messages are still accepted and its\n // process-loss events still recover it. A close belongs to one conversation,\n // and `load` retires it when ownership moves, so nothing is ever born hidden.\n const [closed, setClosed] = useState<'ended' | 'minimised' | null>(null)\n\n // A `/logout` that has not confirmed yet. It owns the screen while it is set,\n // and no conversation loads or renders for anyone: the page clears the chat's\n // cookies and this frame's storage only by running in the frame, so a host\n // signing the next customer in before it confirms would unmount it mid-clear\n // and leave the previous customer's credentials on the device. That sequence,\n // logout immediately followed by the next sign-in on a shared device, is the\n // case the feature exists for, so it is the one that must not race.\n //\n // Nothing clears this but the page's own confirmation, so a logout that never\n // confirms fails closed: no next chat starts. `logout_failed` says so, and\n // calling `logout()` again retries.\n const [clearing, setClearing] = useState<{\n attempt: number\n // Latched with the URI, not read from props. A retry belongs to the customer\n // whose logout is still pending, and the host may well have moved on to the\n // next one's credentials by then: rebuilding from props would retry the\n // wrong customer, leave the first one's credentials on the device, and latch\n // the second one out of a chat they never left.\n // Optional like the prop: the WebView applies the default, so latching an\n // absent one keeps the same origin it would have used anyway.\n baseUrl: string | undefined\n identity: string\n uri: string\n // Latched for the same reason as the origin. The cookies this page is\n // clearing are in whichever store the conversation used, and the host may\n // have re-rendered without that configuration by the time the page loads.\n webviewProps: WebViewProps\n } | null>(null)\n // The credentials a logout retired. Keeps the conversation from coming back\n // once the page has confirmed, and is released when the credentials move on,\n // so a customer returning to a shared device is not left on a widget that\n // never loads. Released only while nothing is clearing, or the release would\n // be the race above.\n const [loggedOutIdentity, setLoggedOutIdentity] = useState<string | null>(\n null,\n )\n // Read by the logout deadline, which must not restart because a host passed a\n // fresh inline callback.\n const onEventRef = useRef(onEvent)\n onEventRef.current = onEvent\n\n // Which header the customer sees. The page hides its own when asked, so the\n // SDK's stands in for it, and the two have to be decided together: rendering\n // both stacks them, and rendering neither leaves no way to Close or End chat.\n const hideHeader = customisations?.hideHeader ?? true\n\n const load = useCallback(\n (\n client: LorikeetClient,\n forIdentity: string,\n intendedTicketId: string | undefined,\n resume: { session?: string; ticketId: string } | null,\n // Recovery has to remount even when the URL is identical, which it is for\n // a host-supplied ticket and for every recovery after the first: the point\n // is to replace a dead or stale WebView, not to navigate it.\n force = false,\n ) => {\n const next = client.getWebviewUrl({\n token: authToken,\n ticket_id: resume?.ticketId,\n session: resume?.session,\n })\n const unchanged = uriRef.current === next\n // Retiring the close here rather than scoping it to the identity that\n // closed it is what stops a host switching customers and back from\n // reapplying a close to a conversation nobody closed. Recovery keeps\n // ownership, so a closed chat is not reopened by a remount, and neither is\n // it by the effect re-running for an inline `metadata` object.\n if (\n loadedForRef.current.identity !== forIdentity ||\n loadedForRef.current.intendedTicketId !== intendedTicketId\n ) {\n setClosed(null)\n }\n // Ownership is recorded even when nothing needs to load. After a refused\n // resume the follow-up effect recomputes the same /widget URL, and\n // skipping this would leave ownership naming the rejected ticket, so the\n // fresh page's own reports would be rejected and its conversation\n // forgotten.\n uriRef.current = next\n loadedForRef.current = { identity: forIdentity, intendedTicketId }\n // An unchanged URL must not remount on its own: a host passing an inline\n // object for `customisations` or `metadata` re-runs the effect on every\n // render, and remounting there would cost the customer their place.\n if (unchanged && !force) {\n // Ownership can still have moved: two spellings of the same base URL\n // produce one URL but different identities. `loaded` gates rendering,\n // so leaving it stale here blanks the widget for good.\n setLoaded((previous) =>\n previous === null ||\n previous.identity !== forIdentity ||\n previous.intendedTicketId !== intendedTicketId\n ? { identity: forIdentity, intendedTicketId, uri: next }\n : previous,\n )\n return\n }\n setLoaded({ identity: forIdentity, intendedTicketId, uri: next })\n setAttemptedTicketId(resume?.ticketId)\n remount()\n },\n [authToken, remount],\n )\n\n useEffect(() => {\n const lorikeetClient = new LorikeetClient({\n public_key: publicKey,\n api_base: baseUrl,\n metadata,\n customisations: { ...customisations, hideHeader },\n })\n setLorikeetClient(lorikeetClient)\n // The logout owns the WebView until it confirms, next customer included.\n if (clearing !== null || loggedOutIdentity === identity) {\n return\n }\n // The previous customer is cleared and these are different credentials, so\n // the next chat is theirs to start.\n if (loggedOutIdentity !== null) {\n setLoggedOutIdentity(null)\n }\n // Rebuilding for a `metadata`, `customisations` or `session` change must not\n // drop a self-created conversation back to /widget, which would mint a\n // second ticket and abandon the one the customer is in.\n const resume =\n resumingTicketId !== undefined\n ? { session, ticketId: resumingTicketId }\n : resumableConversation(observedRef.current, identity, undefined)\n load(lorikeetClient, identity, resumingTicketId, resume)\n }, [\n publicKey,\n baseUrl,\n authToken,\n clearing,\n customisations,\n hideHeader,\n loggedOutIdentity,\n metadata,\n session,\n resumingTicketId,\n identity,\n load,\n ])\n\n /**\n * Clear this customer's chat from the device. Loads the page's own `/logout`,\n * the only thing that can drop the chat's cookies and the storage the page\n * keeps; a host clearing its stored `ticketId` and `session` leaves both\n * behind.\n *\n * Nothing else loads until the page confirms with `logged_out`, and nothing\n * loads for these credentials after it, so pass the next customer's in to\n * start their chat. Clear your own stored `ticketId` and `session` too.\n */\n const logout = useCallback(() => {\n // A conversation that has been logged out must not be resumable, or the next\n // recovery would navigate straight back into it.\n observedRef.current = null\n setClosed(null)\n setClearing((previous) => {\n // Already clearing: this is a retry of that attempt, whoever is in props\n // now. Only the attempt moves, which is enough to remount a page that\n // never confirmed, and the identity it is clearing stays latched.\n if (previous !== null) {\n return { ...previous, attempt: previous.attempt + 1 }\n }\n setLoggedOutIdentity(identity)\n return {\n attempt: 1,\n baseUrl,\n identity,\n // Built here rather than taken from state, so a logout on the first\n // render, before the load effect has run, still reaches the page.\n uri: new LorikeetClient({\n api_base: baseUrl,\n public_key: publicKey,\n }).getLogoutUrl(),\n webviewProps: {\n // The host's configuration decides which store the cookies this page\n // is clearing are actually in. `sharedCookiesEnabled` is the one that\n // matters: without it here, iOS clears the WebKit store while the\n // shared native cookie survives, and the next conversation imports it\n // and reopens the chat this was meant to end. Any storage setting\n // could do the same, so the whole object comes across rather than a\n // list that would date.\n ...webviewProps,\n // Nothing of the host's runs on this page. It is not their\n // conversation and there is nothing on it to interact with, and the\n // bridge is how the clear reports itself.\n injectedJavaScript: undefined,\n injectedJavaScriptBeforeContentLoaded: undefined,\n onMessage: undefined,\n // Theirs is asked before same-origin navigation is allowed, so a\n // guard that allows the routes it knows and refuses the rest would\n // refuse this one and the logout would never load. A page they did\n // not ask for is not theirs to veto.\n onShouldStartLoadWithRequest: undefined,\n },\n }\n })\n }, [baseUrl, identity, publicKey, webviewProps])\n\n /**\n * Show a minimised conversation again, exactly as the customer left it.\n *\n * A no-op unless something is minimised, so a host with its own launcher can\n * call it without first asking.\n */\n const reopen = useCallback(() => {\n // A minimise is the only close a reopen answers. An end retired the\n // conversation, and putting it back would hand the customer a transcript\n // they can do nothing with.\n setClosed((previous) => (previous === 'minimised' ? null : previous))\n }, [])\n\n /**\n * What the widget is actually on, for a host that has to store it or draw its\n * own launcher around it.\n */\n const getConversation = useCallback(() => {\n // Read through the same guard a recovery resumes through, so a conversation\n // observed under credentials that have since changed is never handed back:\n // the ticket route authorises a ticket-bound session independently of the\n // trusted token, so that would be one customer's conversation given to the\n // next.\n const resumable = resumableConversation(\n observedRef.current,\n identity,\n resumingTicketId,\n )\n if (resumable === null) {\n return null\n }\n return {\n minimised: closed === 'minimised',\n session: resumable.session,\n ticketId: resumable.ticketId,\n }\n }, [closed, identity, resumingTicketId])\n\n useImperativeHandle(ref, () => ({ getConversation, logout, reopen }), [\n getConversation,\n logout,\n reopen,\n ])\n\n const clearingAttempt = clearing?.attempt\n\n useEffect(() => {\n if (clearingAttempt === undefined) {\n return\n }\n const timer = setTimeout(() => {\n // Deliberately leaves `clearing` set: a logout that cannot be confirmed\n // must not be followed by the next customer's chat on the same device.\n onEventRef.current?.({ type: 'logout_failed' })\n }, LOGOUT_CONFIRMATION_TIMEOUT_MS)\n return () => {\n clearTimeout(timer)\n }\n }, [clearingAttempt])\n\n const handleClearingEvent = useCallback(\n (event: LorikeetWidgetEvent) => {\n // Only the attempt still on screen can release: a late confirmation from a\n // page a retry replaced says nothing about this one.\n if (event.type === 'logged_out') {\n setClearing((previous) =>\n previous?.attempt === clearingAttempt ? null : previous,\n )\n }\n onEvent?.(event)\n },\n [clearingAttempt, onEvent],\n )\n\n // A WebView whose renderer process was killed is a permanently blank view: it\n // does not recover on its own and the host has no way to notice. Remounting\n // is the only reliable recovery, on both platforms.\n const reload = useCallback(() => {\n const resume = resumableConversation(\n observedRef.current,\n identity,\n resumingTicketId,\n )\n if (lorikeetClient !== null && resume !== null) {\n load(lorikeetClient, identity, resumingTicketId, resume, true)\n return\n }\n remount()\n }, [identity, load, lorikeetClient, remount, resumingTicketId])\n\n useEffect(() => {\n const subscription = AppState.addEventListener('change', (next) => {\n if (next === 'active') {\n if (\n shouldReloadAfterBackground(backgroundedAtRef.current, Date.now())\n ) {\n reload()\n }\n backgroundedAtRef.current = null\n return\n }\n // Only 'background' counts. On iOS 'inactive' also covers Notification\n // Center, the app switcher and call banners, where the app stays on\n // screen and the stream is never suspended; treating those as an absence\n // would remount and discard the customer's draft for nothing.\n if (next === 'background') {\n backgroundedAtRef.current ??= Date.now()\n }\n })\n return () => {\n subscription.remove()\n }\n }, [reload])\n\n const startNewChat = useCallback(() => {\n // The replacement conversation is the one the customer asked for, so a close\n // that hid the page it replaces must not hide this one too. Explicit because\n // a new chat keeps the same ownership, which is what `load` keys off.\n setClosed(null)\n // After a recovery, `uri` is a resume URL even when the host never supplied\n // a ticket, so remounting would reopen the conversation instead of starting\n // one. Retire what was observed, or the next load would resume it anyway.\n if (lorikeetClient !== null && resumingTicketId === undefined) {\n observedRef.current = null\n load(lorikeetClient, identity, undefined, null, true)\n return\n }\n // With a host-supplied ticket the documented behaviour is to reload it; the\n // host starts a new conversation by clearing its own stored ticket.\n remount()\n }, [identity, load, lorikeetClient, remount, resumingTicketId])\n\n const handleEvent = useCallback(\n (event: LorikeetWidgetEvent) => {\n // A handler outlives its page: when credentials or the intended ticket\n // change, the component re-renders and the old WebView is replaced, but\n // events already in flight still arrive at the handler installed on it.\n //\n // `identity` and `resumingTicketId` here are the ones this handler was\n // created with, and stamping observations with those is what keeps a late\n // report from the old page out of the new one's conversation: it gets the\n // old identity, which `resumableConversation` then refuses. That is the\n // load-bearing part, and it matters because the ticket route authorises a\n // ticket-bound session independently of the trusted token, so accepting\n // one would be a cross-customer read rather than the wrong conversation.\n //\n // The gate below is the narrower guarantee that a replaced page cannot\n // speak for the one that replaced it: its errors must not abandon the new\n // conversation, and its reports must not overwrite the new observation.\n const isCurrentPage =\n generation === generationRef.current &&\n loadedForRef.current.identity === identity &&\n loadedForRef.current.intendedTicketId === resumingTicketId\n\n if (isCurrentPage) {\n if (\n event.type === 'persist_ticket_id' ||\n event.type === 'persist_session'\n ) {\n const current =\n observedRef.current?.identity === identity &&\n observedRef.current.intendedTicketId === resumingTicketId\n ? observedRef.current\n : null\n observedRef.current = {\n ...current,\n ...(event.type === 'persist_ticket_id'\n ? { ticketId: event.ticketId }\n : { session: event.session }),\n identity,\n intendedTicketId: resumingTicketId,\n }\n }\n\n // Hide rather than unmount, so the `new_chat` that follows on End chat\n // still has a page to arrive at.\n //\n // This reaches only as far as the SDK's own rendering. The close is\n // still reported on, and a host that unmounts on it destroys the page\n // before the `new_chat` seconds behind it arrives, which is the failure\n // this hide exists to prevent. The SDK cannot stop that: it has no way\n // to ask a host to keep rendering, and the message it would need does\n // not exist: one that says the conversation has ended and it is safe to\n // tear down. That signal is page-side work whose shape is still being\n // agreed, so nothing here invents one. Until then this fixes hosts that\n // leave the widget mounted, and leaves hosts that unmount as they were.\n if (event.type === 'close') {\n setClosed(event.reason === 'minimised' ? 'minimised' : 'ended')\n }\n\n // The page cannot replace itself, so without this the ended conversation\n // stays on screen and the host goes on resuming a ticket that only\n // reopens read-only. Same route as the header control rather than a\n // second way to retire a conversation.\n if (event.type === 'new_chat') {\n startNewChat()\n }\n\n // Only a resume can be abandoned; a fresh /widget load reporting the\n // same code would loop if it retried, and a token error survives the\n // retry.\n if (\n event.type === 'error' &&\n attemptedTicketId !== undefined &&\n isUnusableConversationCode(event.error)\n ) {\n // The conversation is gone, so remembering it would send the remount\n // straight back into the same refusal.\n observedRef.current = null\n setRefusedTicketId(attemptedTicketId)\n // When the refused ticket came from the bridge rather than the prop,\n // nothing upstream changes, so no effect would recompute the URL and\n // the customer would be left on the error page. Start fresh here.\n if (lorikeetClient !== null) {\n // Intent is `undefined`, not the ticket being abandoned: this load\n // is a fresh conversation, and stamping it with the rejected ticket\n // would leave ownership naming something no render will ask for.\n load(lorikeetClient, identity, undefined, null, true)\n }\n }\n }\n\n // Reported whatever the gate decided. A host watching the conversation\n // should hear about a page that is on its way out, not only the one the\n // SDK is still acting for.\n onEvent?.(event)\n },\n [\n attemptedTicketId,\n generation,\n identity,\n load,\n lorikeetClient,\n onEvent,\n resumingTicketId,\n startNewChat,\n ],\n )\n\n // The raw bridge, kept working for hosts that read it before there were\n // events. Deprecated rather than removed: the subscriber is live on this.\n const handleMessage = useCallback(\n (event: WebViewMessageEvent) => {\n onWebViewMessage?.(event)\n webviewProps?.onMessage?.(event)\n },\n [onWebViewMessage, webviewProps],\n )\n\n // A logout holds the screen on its own terms, keyed on the attempt and on the\n // origin it latched rather than on props, so the next customer's sign-in\n // neither unmounts it nor changes where it points: a host that switched\n // `baseUrl` in the same batch would otherwise leave the old `/logout` looking\n // external, and iOS would hand it to the system browser instead of clearing\n // anything.\n //\n // Its own component instance, because a logout is not a conversation: it\n // carries no session, so it stays outside the ownership rules that keep one\n // customer's conversation away from another's credentials.\n //\n // Hidden the way a closed conversation is, which keeps it laid out and running:\n // there is nothing to see on it and nothing to touch.\n if (clearing !== null) {\n return (\n <SafeAreaView edges={['top', 'left', 'right']} style={stylesheet.root}>\n <View\n accessibilityElementsHidden\n importantForAccessibility=\"no-hide-descendants\"\n pointerEvents=\"none\"\n style={stylesheet.closed}\n >\n <LorikeetWidgetWebView\n baseUrl={clearing.baseUrl}\n key={`logout|${clearing.attempt}`}\n onEvent={handleClearingEvent}\n uri={clearing.uri}\n webviewProps={{\n ...clearing.webviewProps,\n style: stylesheet.webView,\n }}\n />\n </View>\n </SafeAreaView>\n )\n }\n\n // Nothing is rendered until the URL has been rebuilt for the credentials now\n // in props. Rendering sooner would mount a WebView pointed at the previous\n // customer's conversation, which their session still authorises.\n //\n // A logged-out customer gets nothing either: `loaded` still holds the\n // conversation they were in, and it must not come back behind them.\n if (\n lorikeetClient === null ||\n loaded === null ||\n loaded.identity !== identity ||\n loaded.intendedTicketId !== resumingTicketId ||\n loggedOutIdentity === identity\n ) {\n return null\n }\n\n const isClosed = closed !== null\n\n return (\n <SafeAreaView edges={['top', 'left', 'right']} style={stylesheet.root}>\n <View style={stylesheet.container}>\n {hideHeader && closed !== 'minimised' ? (\n <LorikeetWidgetHeader\n onIconPress={startNewChat}\n style={styles?.header}\n title={customisations?.title ?? 'Lorikeet AI Agent'}\n />\n ) : null}\n {/*\n A closed page cannot offer its own way forward, so exactly one of\n these is on screen while it is hidden, and nothing that undoes it.\n\n A minimise gets the way back and only that: the header's single\n control retires the conversation rather than restoring it, which is\n why the header stands down above rather than sitting next to this\n offering to discard what the customer put away. An end keeps the\n header, whose control is the right answer there, and new chat stands\n in for it where the header is the page's, because then there is\n nothing else: a close is not always followed by the `startNewChat`\n that would replace the conversation for us.\n */}\n {closed === 'minimised' ? (\n <TouchableOpacity onPress={reopen} style={stylesheet.closedControl}>\n <Text style={stylesheet.headerText}>Reopen chat</Text>\n </TouchableOpacity>\n ) : null}\n {!hideHeader && closed === 'ended' ? (\n <TouchableOpacity\n onPress={startNewChat}\n style={stylesheet.closedControl}\n >\n <Text style={stylesheet.headerText}>Start new chat</Text>\n </TouchableOpacity>\n ) : null}\n {/*\n Wrapped rather than styled through `webviewProps`, which the host can\n override: a host passing its own `style` would otherwise be able to\n keep a closed conversation on screen.\n\n Out of sight rather than out of the layout, and the other three props\n are what make that safe. A view at zero opacity is still touchable and\n still read out by a screen reader, so a conversation the customer has\n put away would otherwise remain both, invisibly.\n */}\n <View\n accessibilityElementsHidden={isClosed}\n importantForAccessibility={isClosed ? 'no-hide-descendants' : 'auto'}\n pointerEvents={isClosed ? 'none' : 'auto'}\n style={isClosed ? stylesheet.closed : stylesheet.webView}\n >\n <LorikeetWidgetWebView\n baseUrl={lorikeetClient.baseUrl}\n // The identity is in the key so that a credential or ticket change\n // unmounts the old WebView on the very render that changes it. Were\n // it keyed on `generation` alone, the old WebView would survive that\n // render holding the new `onMessage`, and a persistence event from\n // the page it is still showing would be recorded against the new\n // identity: a later recovery could then reopen the previous\n // customer's conversation. The window is between the effect that\n // bumps `generation` and the commit that unmounts, so no test here\n // reaches it; the component harness cannot deliver a message between\n // React's commit phases.\n key={`${identity}|${resumingTicketId ?? ''}|${generation}`}\n onEvent={handleEvent}\n ref={webViewRef}\n uri={loaded.uri}\n webviewProps={{\n allowsInlineMediaPlayback: true,\n mediaPlaybackRequiresUserAction: false,\n scalesPageToFit: true,\n startInLoadingState: true,\n style: stylesheet.webView,\n ...webviewProps,\n // The bridge and the crash recovery are the SDK's job, so these\n // wrap the host's handlers instead of yielding to them.\n onMessage: handleMessage,\n // Gated like the bridge handler: a process-loss event queued by a\n // WebView that has since been replaced would reload against the\n // ownership of that render, and the gate on `loaded` would then\n // never be satisfiable again.\n //\n // `onContentProcessDidTerminate` is not exclusively a real process\n // death. `react-native-webview` synthesises one whenever it tears\n // the WebView down: `destroyWebView` ends by nilling `_webView` and\n // then calling this handler, and Fabric calls it from\n // `prepareForRecycle`. So a recycled view can arrive here looking\n // like a crash. Measured on the close path and it did not fire, but\n // the call exists, and the generation gate is what keeps a stray one\n // from reloading against a page that is already gone.\n onRenderProcessGone: (event) => {\n if (generation === generationRef.current) {\n reload()\n }\n webviewProps?.onRenderProcessGone?.(event)\n },\n onContentProcessDidTerminate: (event) => {\n if (generation === generationRef.current) {\n reload()\n }\n webviewProps?.onContentProcessDidTerminate?.(event)\n },\n }}\n />\n </View>\n </View>\n </SafeAreaView>\n )\n})\nLorikeetWidgetView.displayName = 'LorikeetWidgetView'\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/bridge.ts","../src/encode.ts","../src/client.ts","../src/errors.ts","../src/events.ts","../src/lifecycle.ts","../src/links.ts","../src/message-circle-plus-icon.tsx","../src/widget.tsx"],"sourcesContent":["/**\n * The conversation the live WebView is actually in, which is not necessarily the\n * one the host passed as `ticketId`: a mount without one creates a ticket, and\n * only the bridge knows its id.\n */\nexport type ObservedConversation = {\n /** The credentials this conversation was created under. */\n identity: string\n /**\n * The ticket the mount was aiming for when this was observed, or `undefined`\n * for a mount that created its own. A host switching to another conversation\n * changes it, which retires the observation: without that, a recovery in the\n * window before the new page reports itself would navigate back to the old\n * ticket and undo the switch.\n */\n intendedTicketId?: string\n session?: string\n ticketId?: string\n}\n\n/**\n * The public key, base URL and token are one unit, and a conversation belongs to\n * the credentials that created it. Stamping an observation with them is what\n * stops a remount resuming one environment's ticket against another's key.\n */\nexport const conversationIdentity = ({\n authToken,\n baseUrl,\n publicKey,\n}: {\n authToken: string\n baseUrl?: string\n publicKey: string\n}): string => `${publicKey} ${baseUrl ?? ''} ${authToken}`\n\n/**\n * A remount has to resume the conversation it was in, or the recovery abandons\n * the ticket it was meant to rescue. Only usable when the credentials still\n * match the ones that produced it.\n */\nexport const resumableConversation = (\n observed: ObservedConversation | null,\n identity: string,\n intendedTicketId: string | undefined,\n): { session?: string; ticketId: string } | null => {\n if (\n observed === null ||\n observed.identity !== identity ||\n observed.intendedTicketId !== intendedTicketId ||\n observed.ticketId === undefined\n ) {\n return null\n }\n return { session: observed.session, ticketId: observed.ticketId }\n}\n","const BASE64_ALPHABET =\n 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n\n/**\n * Base64 of a string's UTF-8 bytes.\n *\n * The chat page decodes this as UTF-8 (`Buffer.from(value, 'base64')`), so\n * UTF-8 is the contract. The global `btoa` cannot meet it: it accepts Latin-1\n * only, so a typographic apostrophe or an emoji in a title throws\n * \"Found invalid character when converting to base64\" and takes the render with\n * it. Hermes also had no `btoa` at all before React Native 0.74, which is\n * inside this package's supported peer range.\n */\nexport function base64EncodeUtf8(value: string): string {\n // encodeURIComponent is UTF-8 by specification, so percent-decoding its\n // output one byte at a time yields the UTF-8 encoding without needing\n // TextEncoder, which has the same React Native availability problem as btoa.\n const bytes = encodeURIComponent(value).replace(\n /%([0-9A-F]{2})/g,\n (_, hex: string) => String.fromCharCode(parseInt(hex, 16)),\n )\n\n let encoded = ''\n for (let index = 0; index < bytes.length; index += 3) {\n const first = bytes.charCodeAt(index)\n const second =\n index + 1 < bytes.length ? bytes.charCodeAt(index + 1) : undefined\n const third =\n index + 2 < bytes.length ? bytes.charCodeAt(index + 2) : undefined\n\n encoded += BASE64_ALPHABET[first >> 2]\n encoded += BASE64_ALPHABET[((first & 0x03) << 4) | ((second ?? 0) >> 4)]\n encoded +=\n second === undefined\n ? '='\n : BASE64_ALPHABET[((second & 0x0f) << 2) | ((third ?? 0) >> 6)]\n encoded += third === undefined ? '=' : BASE64_ALPHABET[third & 0x3f]\n }\n\n return encoded\n}\n","import type { ConversationMetadata } from './conversation-metadata'\nimport type { Customisations } from './customisations'\nimport { base64EncodeUtf8 } from './encode'\nimport type { LorikeetInitOptions } from './types'\n\nexport class LorikeetClient {\n baseUrl: string = 'https://chat.lorikeetcx.ai' // Fallback URL\n customisations: Customisations | null = null\n metadata: ConversationMetadata | null = null\n publicKey: string\n\n constructor({\n customisations,\n api_base,\n metadata,\n public_key,\n }: LorikeetInitOptions) {\n if (api_base) {\n this.baseUrl = api_base\n }\n this.publicKey = public_key\n\n if (customisations) {\n this.customisations = customisations\n }\n\n if (metadata) {\n this.metadata = metadata\n }\n }\n\n getWebviewUrl = ({\n customer_email,\n customer_name,\n token,\n ticket_id,\n session,\n }: {\n customer_email?: string\n customer_name?: string\n session?: string\n ticket_id?: string\n token: string\n }) => {\n const url = new URL(this.baseUrl)\n\n url.pathname = ticket_id\n ? `/tickets/${this.publicKey}/${ticket_id}`\n : '/widget'\n url.searchParams.append('public_key', this.publicKey)\n\n if (customer_email) {\n url.searchParams.append('customer_email', customer_email)\n }\n if (customer_name) {\n url.searchParams.append('customer_name', customer_name)\n }\n\n url.searchParams.append('token', token)\n\n // The session authorises exactly the ticket it was issued for, so it only\n // means anything on the resume path. Without it the ticket route cannot\n // authorise a fresh WebView and answers CUSTOMER_ID_MISSING.\n if (ticket_id && session) {\n url.searchParams.append('session', session)\n }\n\n if (this.customisations) {\n url.searchParams.append(\n 'customisations',\n base64EncodeUtf8(JSON.stringify(this.customisations)),\n )\n }\n\n // Plain JSON, unlike customisations. The routes read it with\n // `searchParams.get('metadata')` and parse it directly.\n if (this.metadata) {\n url.searchParams.append('metadata', JSON.stringify(this.metadata))\n }\n\n return url.toString()\n }\n\n /**\n * The page that clears this key's credentials from the WebView: its cookies,\n * server-side, and the storage the frame keeps. Navigating a WebView here is\n * the only way to reach either, so a host that only drops its own copy of\n * `ticketId` and `session` leaves the next person on the device able to\n * reopen the conversation.\n *\n * The page confirms with a `loggedOut` message, which the SDK reports as\n * `logged_out`. The load event does not: an error page fires that too.\n */\n getLogoutUrl = () => {\n const url = new URL(this.baseUrl)\n url.pathname = '/logout'\n url.searchParams.append('public_key', this.publicKey)\n return url.toString()\n }\n}\n","/**\n * The chat page's error vocabulary, as it reaches a host in\n * `{ type: 'error', error: CODE }`. Mirrored here because the page publishes it\n * nowhere else, so a host would otherwise be matching on strings it discovered\n * by observation.\n *\n * The page's own list rather than a list of codes seen in the wild:\n * `MISSING_PUBLIC_KEY` has no producer today, and is kept because narrowing this\n * would be a breaking change.\n */\nexport const LORIKEET_ERROR_CODES = [\n 'MISSING_TOKEN',\n 'CORRUPT_TOKEN_DATA',\n 'DECRYPT_FAILED',\n 'EXPIRED_TOKEN',\n 'MALFORMED_TOKEN',\n 'MISSING_PUBLIC_KEY',\n 'UNKNOWN_PUBLIC_KEY',\n 'CUSTOMER_ID_CONFLICT',\n 'CUSTOMER_ID_MISSING',\n 'FATAL_ERROR',\n 'INVALID_SESSION',\n 'DOMAIN_NOT_ALLOWED',\n 'TICKET_NOT_FOUND',\n] as const\n\nexport type LorikeetErrorCode = (typeof LORIKEET_ERROR_CODES)[number]\n\n/**\n * The codes that mean the stored conversation cannot be reopened, as opposed to\n * the trusted token being bad or the integration being misconfigured. Only\n * these are worth abandoning a resume for: a token error fails again with the\n * same token, and a configuration error fails again regardless.\n */\nconst UNUSABLE_CONVERSATION_CODES: readonly LorikeetErrorCode[] = [\n 'CUSTOMER_ID_MISSING',\n 'INVALID_SESSION',\n 'TICKET_NOT_FOUND',\n]\n\n/** Whether an error code means the stored conversation cannot be reopened. */\nexport function isUnusableConversationCode(code: string): boolean {\n return UNUSABLE_CONVERSATION_CODES.includes(code as LorikeetErrorCode)\n}\n\n// oxlint-disable-next-line @lorikeetai/no-unknown-parameters -- Hosts pass raw JSON posted by the chat page, which may be any message.\nexport function isUnusableConversation(message: unknown): boolean {\n if (typeof message !== 'object' || message === null) {\n return false\n }\n const { type, error } = message as { error?: unknown; type?: unknown }\n return type === 'error' && typeof error === 'string'\n ? isUnusableConversationCode(error)\n : false\n}\n","/**\n * What the page reports, as events rather than as raw bridge messages.\n *\n * Names follow the web SDK's (`LorikeetEvent` in `@lorikeetai/web-sdk/types`)\n * wherever it has one, because a host that integrates both should not have to\n * learn two vocabularies. The rest cover what the page tells a host that owns\n * its own layout and storage, which the web SDK largely handles for itself: it\n * surfaces a close without its reason, a created ticket without the stored pair\n * behind it, and `EXPIRED_TOKEN` alone of the error codes. `logout_failed` is\n * the one the SDK reports itself, for a page that said nothing when it had to.\n *\n * `token_expiry` is deliberately absent, and the anonymous session token with\n * it. The credential model they belong to is being rewritten, and an event is\n * the hardest thing to take back. `persist_session` is translated despite\n * carrying a credential, because a resume cannot be rebuilt without it.\n */\nexport type LorikeetWidgetEvent =\n /** The page is up and the conversation is usable. */\n | { type: 'ready' }\n /**\n * A ticket that did not exist before this page loaded. Once per ticket, and\n * never for a resume. The web SDK suppresses a resume too, but counts once\n * per session rather than per ticket, so this is the stricter of the two.\n */\n | { type: 'ticket_created'; ticketId: string }\n /** The customer sent something. */\n | { type: 'customer_message' }\n /**\n * The agent sent something. `markUnread` is the page asking for an unread\n * indicator, which it only does where its live chat experience is on; the web\n * SDK's launcher dot is driven by it, and it badges only while the widget is\n * out of sight.\n *\n * Absent from a page older than the release that added it, which is not the\n * same as `false`.\n */\n | { type: 'agent_message'; markUnread?: boolean }\n /**\n * The page closed itself, and why. `ended_by_customer` is usually followed by\n * a `new_chat`, once the end and any CSAT have settled, so a host that tears\n * down here never sees the rest of the handshake. Usually rather than always:\n * a CSAT rating that fails holds the replacement back, and nothing but another\n * close releases it. `minimised` has nothing behind it: the conversation is\n * intact and hiding it is enough.\n *\n * `reason` is absent from a page older than the release that added it, from one\n * whose host lifecycle events are switched off, and from the closes the page\n * makes on its own behalf, its error boundary among them. An absent reason\n * reads as an end, so a minimise is only distinguishable when the page names\n * it.\n */\n | { type: 'close'; reason?: 'ended_by_customer' | 'minimised' }\n /**\n * The conversation ended without the customer ending it: an agent closed it,\n * or it timed out. Nothing follows, and nothing is asking the host to hide\n * anything. Left unhandled, this is the customer stranded on a conversation\n * that can no longer be used.\n *\n * Reported only by a page whose host lifecycle events are on, so it is not\n * the only way an ended conversation can reach a host.\n */\n | { type: 'conversation_ended' }\n /**\n * The page asking for a replacement conversation. It cannot navigate itself,\n * so acting on this is the host's half of End chat.\n */\n | { type: 'new_chat' }\n /**\n * Store this against the customer, alongside the session. A resume needs both:\n * the ticket alone is refused, usually as `CUSTOMER_ID_MISSING`.\n */\n | { type: 'persist_ticket_id'; ticketId: string }\n /**\n * The other half of the stored pair, as its own message. The SDK does not rely\n * on which half arrives first, and neither should a host.\n */\n | { type: 'persist_session'; session: string }\n /**\n * `/logout` cleared the frame: the chat's cookies for this key, server-side,\n * and the storage the page kept. Reached by navigating the WebView to\n * `LorikeetClient.getLogoutUrl()`, and the only confirmation that the\n * clearing happened. The page's load event is not: an error page fires that\n * too.\n *\n * `persistAnonymousSessionToken` is deliberately not translated. It carries a\n * 365-day identity credential, this SDK offers no way to use one, and the web\n * SDK keeps the same message inside the frame rather than handing it to its\n * host. A typed event would be a published commitment to storing it, days\n * before the hand-off protocol is meant to stop putting it in a URL at all.\n */\n | { type: 'logged_out' }\n /**\n * The SDK's own report, not the page's: `/logout` did not confirm inside\n * `LOGOUT_CONFIRMATION_TIMEOUT_MS`, so the previous customer may still be\n * signed in on this device.\n *\n * The widget stays unavailable rather than starting the next customer's chat,\n * because the alternative is handing them a device the last one's credentials\n * still open. Call `logout()` again to retry.\n */\n | { type: 'logout_failed' }\n /** A page-level failure. `LORIKEET_ERROR_CODES` lists the codes. */\n | { type: 'error'; error: string }\n /**\n * The preload page finished warming, and what the server said about it.\n *\n * Unreachable from React Native, and published in error. The preload page\n * posts straight to `window.parent` behind a `window.parent !== window`\n * guard, which is false in a WebView, so it never takes the native bridge the\n * page's other messages go through. Kept because removing a published union\n * member is a breaking change; retire it when 2.x leaves alpha, or when the\n * page learns to post it natively.\n */\n | {\n type: 'preload_complete'\n clientStart: boolean\n lazyTicketCreationEnabled: boolean\n }\n\n/** The names, spelled out rather than derived, so callers import a real type. */\nexport type LorikeetWidgetEventType =\n | 'agent_message'\n | 'close'\n | 'conversation_ended'\n | 'customer_message'\n | 'error'\n | 'logged_out'\n | 'logout_failed'\n | 'new_chat'\n | 'persist_session'\n | 'persist_ticket_id'\n | 'preload_complete'\n | 'ready'\n | 'ticket_created'\n\n/** What `JSON.parse` can produce from a bridge message. */\nexport type BridgeMessageJson =\n | string\n | number\n | boolean\n | null\n | BridgeMessageJson[]\n | { [key: string]: BridgeMessageJson }\n\nconst asRecord = (message: BridgeMessageJson | undefined) =>\n typeof message === 'object' && message !== null && !Array.isArray(message)\n ? message\n : null\n\nconst asNonEmptyString = (value: BridgeMessageJson | undefined) =>\n typeof value === 'string' && value !== '' ? value : null\n\n/**\n * The event a bridge message carries, or `null` for one that carries none.\n *\n * `ticket_created` is not derived here: telling a fresh ticket from a resumed\n * one needs the URL the page was loaded with, which only the component holds.\n */\nexport const eventFromMessage = (\n message: BridgeMessageJson | undefined,\n): LorikeetWidgetEvent | null => {\n const record = asRecord(message)\n if (record === null || typeof record.type !== 'string') {\n return null\n }\n switch (record.type) {\n case 'ready':\n return { type: 'ready' }\n case 'customerMessage':\n return { type: 'customer_message' }\n case 'agentMessage':\n // Reported only when the page said so, so a host can tell \"not worth a\n // badge\" from a page too old to have an opinion.\n return typeof record.markUnread === 'boolean'\n ? { type: 'agent_message', markUnread: record.markUnread }\n : { type: 'agent_message' }\n case 'closeChat': {\n const reason = asNonEmptyString(record.reason)\n return reason === 'ended_by_customer' || reason === 'minimised'\n ? { type: 'close', reason }\n : { type: 'close' }\n }\n case 'conversationEnded':\n return { type: 'conversation_ended' }\n case 'startNewChat':\n return { type: 'new_chat' }\n case 'persistLorikeetTicketId': {\n const ticketId = asNonEmptyString(record.ticketId)\n return ticketId === null ? null : { type: 'persist_ticket_id', ticketId }\n }\n case 'persistLorikeetSession': {\n const session = asNonEmptyString(record.session)\n return session === null ? null : { type: 'persist_session', session }\n }\n case 'loggedOut':\n return { type: 'logged_out' }\n case 'error': {\n const error = asNonEmptyString(record.error)\n return error === null ? null : { type: 'error', error }\n }\n case 'preloadComplete':\n return {\n type: 'preload_complete',\n clientStart: record.clientStart === true,\n lazyTicketCreationEnabled: record.lazyTicketCreationEnabled === true,\n }\n default:\n return null\n }\n}\n\n/**\n * The ticket a URL is trying to resume, or `null` for one that creates its own.\n *\n * Read from the URL rather than from any state, so it holds for a host that\n * built the URL itself. An unreadable URL reports nothing, which at worst\n * announces a resumed ticket as created once.\n */\nexport const resumedTicketIdFromUri = (uri: string): string | null => {\n let pathname: string\n try {\n pathname = new URL(uri).pathname\n } catch {\n return null\n }\n // `/tickets/:publicKey/:ticketId`, which is what `getWebviewUrl` builds and\n // what the chat app routes.\n const segments = pathname.split('/').filter((part) => part !== '')\n if (segments.length < 3 || segments[0] !== 'tickets') {\n return null\n }\n return segments[2] ?? null\n}\n","/**\n * The page gives its message stream ten attempts with exponential backoff\n * capped at 30s (`message-stream-utils.tsx`), so the budget runs out roughly\n * 151s after the stream drops, or ~196s once jitter is counted. It then sits at\n * `failed` with nothing to reset it, so a customer who comes back to a\n * backgrounded app is looking at a stream that will never reconnect.\n *\n * Three minutes sits inside that band. Shorter would remount while the page's\n * own retries could still win, which costs the customer their draft message for\n * nothing; much longer leaves a dead stream on screen.\n */\nexport const BACKGROUND_RELOAD_THRESHOLD_MS = 180_000\n\n/**\n * How long `/logout` gets to confirm it cleared the frame before the host is\n * told it did not. Matches the web SDK's own budget, and like it, a logout that\n * misses the deadline leaves the widget unavailable rather than letting the next\n * customer start a chat on a device the previous one's credentials still open.\n *\n * `LorikeetWidgetView` applies this to its own `logout()`. Exported because a\n * composed host has to run the timer itself and nothing else will report\n * `logout_failed` for it, and two hosts choosing their own budgets is how one\n * app calls a clear failed that the other never does.\n */\nexport const LOGOUT_CONFIRMATION_TIMEOUT_MS = 10_000\n\n/**\n * Elapsed time is the only signal available: the bridge reports `ready`,\n * `error` and the persistence messages, but never stream health, so the SDK\n * cannot ask whether the connection actually survived.\n */\nexport const shouldReloadAfterBackground = (\n backgroundedAt: number | null,\n now: number,\n thresholdMs: number = BACKGROUND_RELOAD_THRESHOLD_MS,\n): boolean => {\n if (backgroundedAt === null) {\n return false\n }\n // A clock that moved backwards says nothing about how long we were away.\n const elapsed = now - backgroundedAt\n if (elapsed < 0) {\n return false\n }\n return elapsed >= thresholdMs\n}\n","/**\n * Schemes the WebView resolves for itself. None of them is a page the customer\n * asked to leave the app for, and the OS cannot open them anyway.\n */\nconst WEBVIEW_OWNED_SCHEMES = new Set([\n 'about:',\n 'blob:',\n 'data:',\n 'javascript:',\n])\n\nconst parseUrl = (value: string): URL | null => {\n try {\n return new URL(value)\n } catch {\n return null\n }\n}\n\n/**\n * Whether the OS could be asked to open this at all. `window.open()` with no\n * argument gives `about:blank`, and a scheme the WebView resolves for itself is\n * not something the OS can place.\n */\nexport const canOpenOutsideApp = (url: string): boolean => {\n const target = parseUrl(url)\n return target !== null && !WEBVIEW_OWNED_SCHEMES.has(target.protocol)\n}\n\n/**\n * Whether a navigation belongs inside the widget.\n *\n * An origin comparison rather than the substring match this replaces. `baseUrl`\n * appearing anywhere in a URL counted as internal, so\n * `https://evil.example/?next=https://chat.lorikeetcx.ai` loaded in place of the\n * conversation instead of opening in the browser. Comparing origins rather than\n * matching a prefix also means a port, a trailing slash and a different path all\n * still resolve to the same widget.\n */\nexport const isInternalNavigation = (url: string, baseUrl: string): boolean => {\n const base = parseUrl(baseUrl)\n const target = parseUrl(url)\n // With either side unparseable there is no basis for sending the customer out\n // of the app, so the navigation stays with the WebView. Failing the other way\n // would hand the chat's own URL, and the session in it, to the system browser.\n if (base === null || target === null) {\n return true\n }\n if (WEBVIEW_OWNED_SCHEMES.has(target.protocol)) {\n return true\n }\n return target.origin === base.origin\n}\n","import Svg, { Line, Path } from 'react-native-svg'\n\ninterface MessageCirclePlusIconProps {\n color?: string\n size?: number\n strokeWidth?: number\n}\n\nexport const MessageCirclePlusIcon = ({\n size = 24,\n color = 'currentColor',\n strokeWidth = 2,\n}: MessageCirclePlusIconProps) => {\n return (\n <Svg fill=\"none\" height={size} viewBox=\"0 0 24 24\" width={size}>\n <Path\n d=\"M7.9 20A9 9 0 1 0 4 16.1L2 22Z\"\n stroke={color}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={strokeWidth}\n />\n <Line\n stroke={color}\n strokeLinecap=\"round\"\n strokeWidth={strokeWidth}\n x1=\"12\"\n x2=\"12\"\n y1=\"8\"\n y2=\"16\"\n />\n <Line\n stroke={color}\n strokeLinecap=\"round\"\n strokeWidth={strokeWidth}\n x1=\"8\"\n x2=\"16\"\n y1=\"12\"\n y2=\"12\"\n />\n </Svg>\n )\n}\n","import {\n forwardRef,\n useCallback,\n useEffect,\n useImperativeHandle,\n useRef,\n useState,\n} from 'react'\n\nimport {\n AppState,\n Linking,\n StyleSheet,\n Text,\n TouchableOpacity,\n View,\n} from 'react-native'\nimport { SafeAreaView } from 'react-native-safe-area-context'\nimport { WebView } from 'react-native-webview'\nimport type { WebViewMessageEvent, WebViewProps } from 'react-native-webview'\n\nimport { conversationIdentity, resumableConversation } from './bridge'\nimport type { ObservedConversation } from './bridge'\nimport { LorikeetClient } from './client'\nimport { isUnusableConversationCode } from './errors'\nimport { eventFromMessage, resumedTicketIdFromUri } from './events'\nimport type { BridgeMessageJson, LorikeetWidgetEvent } from './events'\nimport {\n LOGOUT_CONFIRMATION_TIMEOUT_MS,\n shouldReloadAfterBackground,\n} from './lifecycle'\nimport { canOpenOutsideApp, isInternalNavigation } from './links'\nimport { MessageCirclePlusIcon } from './message-circle-plus-icon'\nimport type {\n LorikeetWidgetHandle,\n LorikeetWidgetHeaderProps,\n LorikeetWidgetViewProps,\n LorikeetWidgetWebViewProps,\n} from './types'\n\nconst stylesheet = StyleSheet.create({\n root: { flex: 1, backgroundColor: '#fff' },\n container: {\n flex: 1,\n borderRadius: 8,\n overflow: 'hidden',\n shadowColor: '#000',\n shadowOffset: { width: 0, height: 2 },\n shadowOpacity: 0.1,\n shadowRadius: 4,\n elevation: 3,\n },\n header: {\n padding: 16,\n display: 'flex',\n flexDirection: 'row',\n justifyContent: 'space-between',\n },\n headerIcon: {\n width: 24,\n },\n headerText: {\n textAlign: 'center',\n fontSize: 16,\n },\n closedControl: {\n padding: 16,\n },\n webView: {\n flex: 1,\n },\n // A closed page is out of sight but still laid out, still full size and still\n // in the view hierarchy.\n //\n // `display: 'none'` is the obvious way to hide it and is the one that broke:\n // on iOS the page stopped running, and the page is what posts the\n // `startNewChat` this is waiting for. Why it stopped is not established. The\n // hiding alone does not explain it: hiding a WKWebView, by zero size or by\n // `hidden` on it or on its parent, leaves the page running in isolation, so\n // something further up is implicated, with Fabric recycling the component view\n // the leading candidate, since react-native-webview destroys the WebView on\n // recycle. Whatever the cause, the requirement is the same and is worth\n // stating on its own terms: a close must not alter the view structurally,\n // because the page has to keep running for seconds afterwards.\n closed: {\n flex: 1,\n opacity: 0,\n },\n})\n\nexport const LorikeetWidgetWebView = forwardRef<\n WebView,\n LorikeetWidgetWebViewProps\n>(\n (\n { uri, baseUrl = 'https://chat.lorikeetcx.ai', webviewProps = {}, onEvent },\n ref,\n ) => {\n const isInternal = (url: string) => isInternalNavigation(url, baseUrl)\n // Every ticket this component already knows about, whether it announced it\n // or the URL asked to resume it. `ticket_created` fires once per ticket and\n // never for a resume, and this is what both of those come down to.\n //\n // Deliberately not reset when `uri` changes. A page that has been navigated\n // away from can still post, and its late report would otherwise be read\n // against the URL that replaced it: the ticket it names is neither the new\n // URL's resume nor yet announced, so it would be announced a second time.\n // Remembering the ticket rather than the page is what closes that without\n // giving this component a generation to track, which belongs to whoever\n // owns the URL.\n const accounted = useRef<Set<string>>(new Set())\n const seenUri = useRef<string | null>(null)\n if (seenUri.current !== uri) {\n seenUri.current = uri\n const resumed = resumedTicketIdFromUri(uri)\n if (resumed !== null) {\n accounted.current.add(resumed)\n }\n }\n\n const emit = (event: LorikeetWidgetEvent) => {\n onEvent?.(event)\n if (event.type !== 'persist_ticket_id') {\n return\n }\n if (accounted.current.has(event.ticketId)) {\n return\n }\n accounted.current.add(event.ticketId)\n onEvent?.({ ticketId: event.ticketId, type: 'ticket_created' })\n }\n\n return (\n <WebView\n style={stylesheet.webView}\n {...webviewProps}\n // The one place bridge messages are read, so the drop-in and a composed\n // host are told the same things by the same code. The raw handler still\n // runs afterwards: this is additive, not a replacement.\n onMessage={(event) => {\n let message: BridgeMessageJson | undefined\n try {\n message = JSON.parse(event.nativeEvent.data)\n } catch {\n // Not JSON, so not one of the page's messages. Pass it through.\n }\n const widgetEvent = eventFromMessage(message)\n if (widgetEvent !== null) {\n emit(widgetEvent)\n }\n webviewProps.onMessage?.(event)\n }}\n // A backstop for a navigation that never reached\n // `onShouldStartLoadWithRequest`, whose coverage differs by platform and by\n // how the page triggered the navigation. The two cannot both open the same\n // URL: one that was turned away there never becomes the navigation state.\n onNavigationStateChange={(navState) => {\n if (!isInternal(navState.url)) {\n void Linking.openURL(navState.url)\n }\n webviewProps.onNavigationStateChange?.(navState)\n }}\n // A second window is the one thing the WebView cannot give the page, so\n // without this a `target=\"_blank\"` link silently does nothing. That is not a\n // corner: the chat sets `<base target=\"_blank\">`, so it is every link in\n // every message, and this rather than the handler below is where most of\n // them arrive.\n //\n // Every target goes out, the widget's own origin included. A browser answers\n // `target=\"_blank\"` with a new tab and leaves the iframe where it is, and\n // the system browser is the nearest thing to that on a phone. Loading it in\n // this WebView instead would replace the conversation the customer is in,\n // with nothing to go back with.\n onOpenWindow={(event) => {\n const { targetUrl } = event.nativeEvent\n if (canOpenOutsideApp(targetUrl)) {\n void Linking.openURL(targetUrl)\n }\n webviewProps.onOpenWindow?.(event)\n }}\n // The only handler that can stop a navigation rather than report one that\n // already started, so this is where an external link is turned away.\n onShouldStartLoadWithRequest={(request) => {\n // Asked first, so a host can claim a navigation, such as a deep link it\n // wants to route itself. Declining is a decision, not an absence of one, so\n // the SDK does not then second-guess it by opening the OS as well.\n if (webviewProps.onShouldStartLoadWithRequest?.(request) === false) {\n return false\n }\n // iOS reports subframe navigations here as well. A frame the page\n // embedded is not a link the customer followed, and lifting it out of the\n // page would break the page.\n //\n // Only an explicit `false` earns that exemption. The peer range is the\n // whole of `react-native-webview` 13, which does not report this field\n // uniformly, and reading a missing one as a subframe would exempt every\n // navigation and leave external pages loading inside the widget.\n if (request.isTopFrame === false || isInternal(request.url)) {\n return true\n }\n void Linking.openURL(request.url)\n return false\n }}\n ref={ref}\n source={{\n uri,\n }}\n />\n )\n },\n)\nLorikeetWidgetWebView.displayName = 'LorikeetWidgetWebView'\n\nexport const LorikeetWidgetHeader = ({\n title,\n style,\n icon = <MessageCirclePlusIcon size={16} />,\n onIconPress,\n}: LorikeetWidgetHeaderProps) => {\n return (\n <View style={stylesheet.header}>\n <View style={stylesheet.headerIcon} />\n <Text style={[stylesheet.headerText, style]}>{title}</Text>\n <TouchableOpacity onPress={onIconPress} style={stylesheet.headerIcon}>\n {icon}\n </TouchableOpacity>\n </View>\n )\n}\n\nexport const LorikeetWidgetView = forwardRef<\n LorikeetWidgetHandle,\n LorikeetWidgetViewProps\n>(function LorikeetWidgetView(\n {\n publicKey,\n baseUrl,\n authToken,\n customisations,\n styles,\n onEvent,\n onWebViewMessage,\n ticketId,\n session,\n metadata,\n webviewProps,\n },\n ref,\n) {\n const [lorikeetClient, setLorikeetClient] = useState<LorikeetClient | null>(\n null,\n )\n // The URL and the identity it belongs to move together in one piece of state.\n // Keeping the URL alone would let a render see the previous identity's resume\n // URL, and the WebView would load that authorised conversation before the\n // effect could redirect it.\n const [loaded, setLoaded] = useState<{\n identity: string\n intendedTicketId?: string\n uri: string\n } | null>(null)\n // A counter, not a timestamp: two reloads inside the same millisecond would\n // produce the same key and silently not remount at all.\n const [generation, setGeneration] = useState(0)\n // Read during render so a handler can tell whether the WebView it was\n // installed on is still the one mounted. Identity and intended ticket cannot\n // answer that: starting a new chat replaces a self-created conversation with\n // another that has exactly the same pair.\n const generationRef = useRef(generation)\n generationRef.current = generation\n const remount = useCallback(() => {\n setGeneration((previous) => previous + 1)\n }, [])\n const webViewRef = useRef<WebView>(null)\n // A resume the server refuses is a dead end on a phone. The ticket route\n // redirects an unauthorised resume to /widget without the trusted token,\n // which answers CUSTOMER_ID_MISSING, and the host has no reason to stop\n // handing the same stale ticket back on the next mount. Give up on that\n // conversation once and start a fresh one instead.\n const [refusedTicketId, setRefusedTicketId] = useState<string | null>(null)\n // What the bridge has said about the conversation on screen. A mount without\n // `ticketId` creates a ticket the host may not have stored yet, so without\n // this a remount would load `/widget`, mint another one, and abandon the\n // conversation the remount was meant to rescue. Held in a ref, not state:\n // reading it must not re-render, and it is deliberately not persisted, since\n // surviving a process death is the host's job.\n const observedRef = useRef<ObservedConversation | null>(null)\n // Outside the listener effect deliberately. That effect re-subscribes whenever\n // `reload` changes, which a new client instance does, and a local would reset\n // to null mid-background and skip the recovery on return.\n const backgroundedAtRef = useRef<number | null>(null)\n // The URL now loaded, and what it was built for. Bridge messages are stamped\n // from this rather than the current props: when credentials change, this\n // component re-renders and replaces the message handler while the old page is\n // still alive, and stamping a late message from that page with the new\n // identity would let a recovery reopen the previous customer's conversation\n // under the next customer's credentials. The ticket route authorises a\n // ticket-bound session independently of the trusted token, so that would be a\n // cross-customer read, not merely the wrong conversation.\n const uriRef = useRef<string | null>(null)\n const loadedForRef = useRef<{\n identity: string\n intendedTicketId?: string\n }>({ identity: '', intendedTicketId: undefined })\n const identity = conversationIdentity({ authToken, baseUrl, publicKey })\n // The ticket this mount will try to resume, if any.\n const resumingTicketId =\n ticketId !== undefined && ticketId !== refusedTicketId\n ? ticketId\n : undefined\n // The ticket the URL now loaded is actually trying to resume, which is not\n // always the prop: a remount resumes what the bridge reported. Refusal has to\n // key off this, or a bridge-derived resume the server rejects leaves the\n // WebView on the error page with every later recovery retrying the same dead\n // ticket.\n const [attemptedTicketId, setAttemptedTicketId] = useState<\n string | undefined\n >(undefined)\n // Whether the page on screen closed itself. End chat posts `closeChat` and\n // then `startNewChat`, so a page torn down on the first never delivers the\n // second: the host keeps the ended ticket and reopens it read-only. Web has\n // the same pair of messages and hides the iframe rather than unloading it.\n //\n // The gap between the two is not a moment. The page posts the second from a\n // later effect that waits on the end and CSAT requests settling, so whatever\n // this does to the page has to leave it running for seconds afterwards. That\n // is what decides how the close hides; see `stylesheet.closed`.\n //\n // A minimise is not an end. It means put this away for a moment, so it keeps\n // the conversation and offers a control that brings it back, where an end\n // offers the new-chat control instead. An absent reason reads as an end: a\n // page that does not report one sends a bare close, and reading those as a\n // minimise would change what every host on such a page already sees.\n //\n // Hiding moves neither `generation` nor `loaded`, so a hidden WebView is still\n // the current page: its bridge messages are still accepted and its\n // process-loss events still recover it. A close belongs to one conversation,\n // and `load` retires it when ownership moves, so nothing is ever born hidden.\n const [closed, setClosed] = useState<'ended' | 'minimised' | null>(null)\n\n // A `/logout` that has not confirmed yet. It owns the screen while it is set,\n // and no conversation loads or renders for anyone: the page clears the chat's\n // cookies and this frame's storage only by running in the frame, so a host\n // signing the next customer in before it confirms would unmount it mid-clear\n // and leave the previous customer's credentials on the device. That sequence,\n // logout immediately followed by the next sign-in on a shared device, is the\n // case the feature exists for, so it is the one that must not race.\n //\n // Nothing clears this but the page's own confirmation, so a logout that never\n // confirms fails closed: no next chat starts. `logout_failed` says so, and\n // calling `logout()` again retries.\n const [clearing, setClearing] = useState<{\n attempt: number\n // Latched with the URI, not read from props. A retry belongs to the customer\n // whose logout is still pending, and the host may well have moved on to the\n // next one's credentials by then: rebuilding from props would retry the\n // wrong customer, leave the first one's credentials on the device, and latch\n // the second one out of a chat they never left.\n // Optional like the prop: the WebView applies the default, so latching an\n // absent one keeps the same origin it would have used anyway.\n baseUrl: string | undefined\n identity: string\n uri: string\n // Latched for the same reason as the origin. The cookies this page is\n // clearing are in whichever store the conversation used, and the host may\n // have re-rendered without that configuration by the time the page loads.\n webviewProps: WebViewProps\n } | null>(null)\n // The credentials a logout retired. Keeps the conversation from coming back\n // once the page has confirmed, and is released when the credentials move on,\n // so a customer returning to a shared device is not left on a widget that\n // never loads. Released only while nothing is clearing, or the release would\n // be the race above.\n const [loggedOutIdentity, setLoggedOutIdentity] = useState<string | null>(\n null,\n )\n // Read by the logout deadline, which must not restart because a host passed a\n // fresh inline callback.\n const onEventRef = useRef(onEvent)\n onEventRef.current = onEvent\n\n // Which header the customer sees. The page hides its own when asked, so the\n // SDK's stands in for it, and the two have to be decided together: rendering\n // both stacks them, and rendering neither leaves no way to Close or End chat.\n const hideHeader = customisations?.hideHeader ?? true\n\n const load = useCallback(\n (\n client: LorikeetClient,\n forIdentity: string,\n intendedTicketId: string | undefined,\n resume: { session?: string; ticketId: string } | null,\n // Recovery has to remount even when the URL is identical, which it is for\n // a host-supplied ticket and for every recovery after the first: the point\n // is to replace a dead or stale WebView, not to navigate it.\n force = false,\n ) => {\n const next = client.getWebviewUrl({\n token: authToken,\n ticket_id: resume?.ticketId,\n session: resume?.session,\n })\n const unchanged = uriRef.current === next\n // Retiring the close here rather than scoping it to the identity that\n // closed it is what stops a host switching customers and back from\n // reapplying a close to a conversation nobody closed. Recovery keeps\n // ownership, so a closed chat is not reopened by a remount, and neither is\n // it by the effect re-running for an inline `metadata` object.\n if (\n loadedForRef.current.identity !== forIdentity ||\n loadedForRef.current.intendedTicketId !== intendedTicketId\n ) {\n setClosed(null)\n }\n // Ownership is recorded even when nothing needs to load. After a refused\n // resume the follow-up effect recomputes the same /widget URL, and\n // skipping this would leave ownership naming the rejected ticket, so the\n // fresh page's own reports would be rejected and its conversation\n // forgotten.\n uriRef.current = next\n loadedForRef.current = { identity: forIdentity, intendedTicketId }\n // An unchanged URL must not remount on its own: a host passing an inline\n // object for `customisations` or `metadata` re-runs the effect on every\n // render, and remounting there would cost the customer their place.\n if (unchanged && !force) {\n // Ownership can still have moved: two spellings of the same base URL\n // produce one URL but different identities. `loaded` gates rendering,\n // so leaving it stale here blanks the widget for good.\n setLoaded((previous) =>\n previous === null ||\n previous.identity !== forIdentity ||\n previous.intendedTicketId !== intendedTicketId\n ? { identity: forIdentity, intendedTicketId, uri: next }\n : previous,\n )\n return\n }\n setLoaded({ identity: forIdentity, intendedTicketId, uri: next })\n setAttemptedTicketId(resume?.ticketId)\n remount()\n },\n [authToken, remount],\n )\n\n useEffect(() => {\n const lorikeetClient = new LorikeetClient({\n public_key: publicKey,\n api_base: baseUrl,\n metadata,\n customisations: { ...customisations, hideHeader },\n })\n setLorikeetClient(lorikeetClient)\n // The logout owns the WebView until it confirms, next customer included.\n if (clearing !== null || loggedOutIdentity === identity) {\n return\n }\n // The previous customer is cleared and these are different credentials, so\n // the next chat is theirs to start.\n if (loggedOutIdentity !== null) {\n setLoggedOutIdentity(null)\n }\n // Rebuilding for a `metadata`, `customisations` or `session` change must not\n // drop a self-created conversation back to /widget, which would mint a\n // second ticket and abandon the one the customer is in.\n const resume =\n resumingTicketId !== undefined\n ? { session, ticketId: resumingTicketId }\n : resumableConversation(observedRef.current, identity, undefined)\n load(lorikeetClient, identity, resumingTicketId, resume)\n }, [\n publicKey,\n baseUrl,\n authToken,\n clearing,\n customisations,\n hideHeader,\n loggedOutIdentity,\n metadata,\n session,\n resumingTicketId,\n identity,\n load,\n ])\n\n /**\n * Clear this customer's chat from the device. Loads the page's own `/logout`,\n * the only thing that can drop the chat's cookies and the storage the page\n * keeps; a host clearing its stored `ticketId` and `session` leaves both\n * behind.\n *\n * Nothing else loads until the page confirms with `logged_out`, and nothing\n * loads for these credentials after it, so pass the next customer's in to\n * start their chat. Clear your own stored `ticketId` and `session` too.\n */\n const logout = useCallback(() => {\n // A conversation that has been logged out must not be resumable, or the next\n // recovery would navigate straight back into it.\n observedRef.current = null\n setClosed(null)\n setClearing((previous) => {\n // Already clearing: this is a retry of that attempt, whoever is in props\n // now. Only the attempt moves, which is enough to remount a page that\n // never confirmed, and the identity it is clearing stays latched.\n if (previous !== null) {\n return { ...previous, attempt: previous.attempt + 1 }\n }\n setLoggedOutIdentity(identity)\n return {\n attempt: 1,\n baseUrl,\n identity,\n // Built here rather than taken from state, so a logout on the first\n // render, before the load effect has run, still reaches the page.\n uri: new LorikeetClient({\n api_base: baseUrl,\n public_key: publicKey,\n }).getLogoutUrl(),\n webviewProps: {\n // The host's configuration decides which store the cookies this page\n // is clearing are actually in. `sharedCookiesEnabled` is the one that\n // matters: without it here, iOS clears the WebKit store while the\n // shared native cookie survives, and the next conversation imports it\n // and reopens the chat this was meant to end. Any storage setting\n // could do the same, so the whole object comes across rather than a\n // list that would date.\n ...webviewProps,\n // Nothing of the host's runs on this page. It is not their\n // conversation and there is nothing on it to interact with, and the\n // bridge is how the clear reports itself.\n injectedJavaScript: undefined,\n injectedJavaScriptBeforeContentLoaded: undefined,\n onMessage: undefined,\n // Theirs is asked before same-origin navigation is allowed, so a\n // guard that allows the routes it knows and refuses the rest would\n // refuse this one and the logout would never load. A page they did\n // not ask for is not theirs to veto.\n onShouldStartLoadWithRequest: undefined,\n },\n }\n })\n }, [baseUrl, identity, publicKey, webviewProps])\n\n /**\n * Show a minimised conversation again, exactly as the customer left it.\n *\n * A no-op unless something is minimised, so a host with its own launcher can\n * call it without first asking.\n */\n const reopen = useCallback(() => {\n // A minimise is the only close a reopen answers. An end retired the\n // conversation, and putting it back would hand the customer a transcript\n // they can do nothing with.\n setClosed((previous) => (previous === 'minimised' ? null : previous))\n }, [])\n\n /**\n * What the widget is actually on, for a host that has to store it or draw its\n * own launcher around it.\n */\n const getConversation = useCallback(() => {\n // Read through the same guard a recovery resumes through, so a conversation\n // observed under credentials that have since changed is never handed back:\n // the ticket route authorises a ticket-bound session independently of the\n // trusted token, so that would be one customer's conversation given to the\n // next.\n const resumable = resumableConversation(\n observedRef.current,\n identity,\n resumingTicketId,\n )\n if (resumable === null) {\n return null\n }\n return {\n minimised: closed === 'minimised',\n session: resumable.session,\n ticketId: resumable.ticketId,\n }\n }, [closed, identity, resumingTicketId])\n\n useImperativeHandle(ref, () => ({ getConversation, logout, reopen }), [\n getConversation,\n logout,\n reopen,\n ])\n\n const clearingAttempt = clearing?.attempt\n\n useEffect(() => {\n if (clearingAttempt === undefined) {\n return\n }\n const timer = setTimeout(() => {\n // Deliberately leaves `clearing` set: a logout that cannot be confirmed\n // must not be followed by the next customer's chat on the same device.\n onEventRef.current?.({ type: 'logout_failed' })\n }, LOGOUT_CONFIRMATION_TIMEOUT_MS)\n return () => {\n clearTimeout(timer)\n }\n }, [clearingAttempt])\n\n const handleClearingEvent = useCallback(\n (event: LorikeetWidgetEvent) => {\n // Only the attempt still on screen can release: a late confirmation from a\n // page a retry replaced says nothing about this one.\n if (event.type === 'logged_out') {\n setClearing((previous) =>\n previous?.attempt === clearingAttempt ? null : previous,\n )\n }\n onEvent?.(event)\n },\n [clearingAttempt, onEvent],\n )\n\n // A WebView whose renderer process was killed is a permanently blank view: it\n // does not recover on its own and the host has no way to notice. Remounting\n // is the only reliable recovery, on both platforms.\n const reload = useCallback(() => {\n const resume = resumableConversation(\n observedRef.current,\n identity,\n resumingTicketId,\n )\n if (lorikeetClient !== null && resume !== null) {\n load(lorikeetClient, identity, resumingTicketId, resume, true)\n return\n }\n remount()\n }, [identity, load, lorikeetClient, remount, resumingTicketId])\n\n useEffect(() => {\n const subscription = AppState.addEventListener('change', (next) => {\n if (next === 'active') {\n if (\n shouldReloadAfterBackground(backgroundedAtRef.current, Date.now())\n ) {\n reload()\n }\n backgroundedAtRef.current = null\n return\n }\n // Only 'background' counts. On iOS 'inactive' also covers Notification\n // Center, the app switcher and call banners, where the app stays on\n // screen and the stream is never suspended; treating those as an absence\n // would remount and discard the customer's draft for nothing.\n if (next === 'background') {\n backgroundedAtRef.current ??= Date.now()\n }\n })\n return () => {\n subscription.remove()\n }\n }, [reload])\n\n const startNewChat = useCallback(() => {\n // The replacement conversation is the one the customer asked for, so a close\n // that hid the page it replaces must not hide this one too. Explicit because\n // a new chat keeps the same ownership, which is what `load` keys off.\n setClosed(null)\n // After a recovery, `uri` is a resume URL even when the host never supplied\n // a ticket, so remounting would reopen the conversation instead of starting\n // one. Retire what was observed, or the next load would resume it anyway.\n if (lorikeetClient !== null && resumingTicketId === undefined) {\n observedRef.current = null\n load(lorikeetClient, identity, undefined, null, true)\n return\n }\n // With a host-supplied ticket the documented behaviour is to reload it; the\n // host starts a new conversation by clearing its own stored ticket.\n remount()\n }, [identity, load, lorikeetClient, remount, resumingTicketId])\n\n const handleEvent = useCallback(\n (event: LorikeetWidgetEvent) => {\n // A handler outlives its page: when credentials or the intended ticket\n // change, the component re-renders and the old WebView is replaced, but\n // events already in flight still arrive at the handler installed on it.\n //\n // `identity` and `resumingTicketId` here are the ones this handler was\n // created with, and stamping observations with those is what keeps a late\n // report from the old page out of the new one's conversation: it gets the\n // old identity, which `resumableConversation` then refuses. That is the\n // load-bearing part, and it matters because the ticket route authorises a\n // ticket-bound session independently of the trusted token, so accepting\n // one would be a cross-customer read rather than the wrong conversation.\n //\n // The gate below is the narrower guarantee that a replaced page cannot\n // speak for the one that replaced it: its errors must not abandon the new\n // conversation, and its reports must not overwrite the new observation.\n const isCurrentPage =\n generation === generationRef.current &&\n loadedForRef.current.identity === identity &&\n loadedForRef.current.intendedTicketId === resumingTicketId\n\n if (isCurrentPage) {\n if (\n event.type === 'persist_ticket_id' ||\n event.type === 'persist_session'\n ) {\n const current =\n observedRef.current?.identity === identity &&\n observedRef.current.intendedTicketId === resumingTicketId\n ? observedRef.current\n : null\n observedRef.current = {\n ...current,\n ...(event.type === 'persist_ticket_id'\n ? { ticketId: event.ticketId }\n : { session: event.session }),\n identity,\n intendedTicketId: resumingTicketId,\n }\n }\n\n // Hide rather than unmount, so the `new_chat` that follows on End chat\n // still has a page to arrive at.\n //\n // This reaches only as far as the SDK's own rendering. The close is\n // still reported on, and a host that unmounts on it destroys the page\n // before the `new_chat` seconds behind it arrives, which is the failure\n // this hide exists to prevent. The SDK cannot stop that: it has no way\n // to ask a host to keep rendering, and the message it would need does\n // not exist: one that says the conversation has ended and it is safe to\n // tear down. That signal is page-side work whose shape is still being\n // agreed, so nothing here invents one. Until then this fixes hosts that\n // leave the widget mounted, and leaves hosts that unmount as they were.\n if (event.type === 'close') {\n setClosed(event.reason === 'minimised' ? 'minimised' : 'ended')\n }\n\n // The page cannot replace itself, so without this the ended conversation\n // stays on screen and the host goes on resuming a ticket that only\n // reopens read-only. Same route as the header control rather than a\n // second way to retire a conversation.\n if (event.type === 'new_chat') {\n startNewChat()\n }\n\n // Only a resume can be abandoned; a fresh /widget load reporting the\n // same code would loop if it retried, and a token error survives the\n // retry.\n if (\n event.type === 'error' &&\n attemptedTicketId !== undefined &&\n isUnusableConversationCode(event.error)\n ) {\n // The conversation is gone, so remembering it would send the remount\n // straight back into the same refusal.\n observedRef.current = null\n setRefusedTicketId(attemptedTicketId)\n // When the refused ticket came from the bridge rather than the prop,\n // nothing upstream changes, so no effect would recompute the URL and\n // the customer would be left on the error page. Start fresh here.\n if (lorikeetClient !== null) {\n // Intent is `undefined`, not the ticket being abandoned: this load\n // is a fresh conversation, and stamping it with the rejected ticket\n // would leave ownership naming something no render will ask for.\n load(lorikeetClient, identity, undefined, null, true)\n }\n }\n }\n\n // Reported whatever the gate decided. A host watching the conversation\n // should hear about a page that is on its way out, not only the one the\n // SDK is still acting for.\n onEvent?.(event)\n },\n [\n attemptedTicketId,\n generation,\n identity,\n load,\n lorikeetClient,\n onEvent,\n resumingTicketId,\n startNewChat,\n ],\n )\n\n // The raw bridge, kept working for hosts that read it before there were\n // events. Deprecated rather than removed: the subscriber is live on this.\n const handleMessage = useCallback(\n (event: WebViewMessageEvent) => {\n onWebViewMessage?.(event)\n webviewProps?.onMessage?.(event)\n },\n [onWebViewMessage, webviewProps],\n )\n\n // A logout holds the screen on its own terms, keyed on the attempt and on the\n // origin it latched rather than on props, so the next customer's sign-in\n // neither unmounts it nor changes where it points: a host that switched\n // `baseUrl` in the same batch would otherwise leave the old `/logout` looking\n // external, and iOS would hand it to the system browser instead of clearing\n // anything.\n //\n // Its own component instance, because a logout is not a conversation: it\n // carries no session, so it stays outside the ownership rules that keep one\n // customer's conversation away from another's credentials.\n //\n // Hidden the way a closed conversation is, which keeps it laid out and running:\n // there is nothing to see on it and nothing to touch.\n if (clearing !== null) {\n return (\n <SafeAreaView edges={['top', 'left', 'right']} style={stylesheet.root}>\n <View\n accessibilityElementsHidden\n importantForAccessibility=\"no-hide-descendants\"\n pointerEvents=\"none\"\n style={stylesheet.closed}\n >\n <LorikeetWidgetWebView\n baseUrl={clearing.baseUrl}\n key={`logout|${clearing.attempt}`}\n onEvent={handleClearingEvent}\n uri={clearing.uri}\n webviewProps={{\n ...clearing.webviewProps,\n style: stylesheet.webView,\n }}\n />\n </View>\n </SafeAreaView>\n )\n }\n\n // Nothing is rendered until the URL has been rebuilt for the credentials now\n // in props. Rendering sooner would mount a WebView pointed at the previous\n // customer's conversation, which their session still authorises.\n //\n // A logged-out customer gets nothing either: `loaded` still holds the\n // conversation they were in, and it must not come back behind them.\n if (\n lorikeetClient === null ||\n loaded === null ||\n loaded.identity !== identity ||\n loaded.intendedTicketId !== resumingTicketId ||\n loggedOutIdentity === identity\n ) {\n return null\n }\n\n const isClosed = closed !== null\n\n return (\n <SafeAreaView edges={['top', 'left', 'right']} style={stylesheet.root}>\n <View style={stylesheet.container}>\n {hideHeader && closed !== 'minimised' ? (\n <LorikeetWidgetHeader\n onIconPress={startNewChat}\n style={styles?.header}\n title={customisations?.title ?? 'Lorikeet AI Agent'}\n />\n ) : null}\n {/*\n A closed page cannot offer its own way forward, so exactly one of\n these is on screen while it is hidden, and nothing that undoes it.\n\n A minimise gets the way back and only that: the header's single\n control retires the conversation rather than restoring it, which is\n why the header stands down above rather than sitting next to this\n offering to discard what the customer put away. An end keeps the\n header, whose control is the right answer there, and new chat stands\n in for it where the header is the page's, because then there is\n nothing else: a close is not always followed by the `startNewChat`\n that would replace the conversation for us.\n */}\n {closed === 'minimised' ? (\n <TouchableOpacity onPress={reopen} style={stylesheet.closedControl}>\n <Text style={stylesheet.headerText}>Reopen chat</Text>\n </TouchableOpacity>\n ) : null}\n {!hideHeader && closed === 'ended' ? (\n <TouchableOpacity\n onPress={startNewChat}\n style={stylesheet.closedControl}\n >\n <Text style={stylesheet.headerText}>Start new chat</Text>\n </TouchableOpacity>\n ) : null}\n {/*\n Wrapped rather than styled through `webviewProps`, which the host can\n override: a host passing its own `style` would otherwise be able to\n keep a closed conversation on screen.\n\n Out of sight rather than out of the layout, and the other three props\n are what make that safe. A view at zero opacity is still touchable and\n still read out by a screen reader, so a conversation the customer has\n put away would otherwise remain both, invisibly.\n */}\n <View\n accessibilityElementsHidden={isClosed}\n importantForAccessibility={isClosed ? 'no-hide-descendants' : 'auto'}\n pointerEvents={isClosed ? 'none' : 'auto'}\n style={isClosed ? stylesheet.closed : stylesheet.webView}\n >\n <LorikeetWidgetWebView\n baseUrl={lorikeetClient.baseUrl}\n // The identity is in the key so that a credential or ticket change\n // unmounts the old WebView on the very render that changes it. Were\n // it keyed on `generation` alone, the old WebView would survive that\n // render holding the new `onMessage`, and a persistence event from\n // the page it is still showing would be recorded against the new\n // identity: a later recovery could then reopen the previous\n // customer's conversation. The window is between the effect that\n // bumps `generation` and the commit that unmounts, so no test here\n // reaches it; the component harness cannot deliver a message between\n // React's commit phases.\n key={`${identity}|${resumingTicketId ?? ''}|${generation}`}\n onEvent={handleEvent}\n ref={webViewRef}\n uri={loaded.uri}\n webviewProps={{\n allowsInlineMediaPlayback: true,\n mediaPlaybackRequiresUserAction: false,\n scalesPageToFit: true,\n startInLoadingState: true,\n style: stylesheet.webView,\n ...webviewProps,\n // The bridge and the crash recovery are the SDK's job, so these\n // wrap the host's handlers instead of yielding to them.\n onMessage: handleMessage,\n // Gated like the bridge handler: a process-loss event queued by a\n // WebView that has since been replaced would reload against the\n // ownership of that render, and the gate on `loaded` would then\n // never be satisfiable again.\n //\n // `onContentProcessDidTerminate` is not exclusively a real process\n // death. `react-native-webview` synthesises one whenever it tears\n // the WebView down: `destroyWebView` ends by nilling `_webView` and\n // then calling this handler, and Fabric calls it from\n // `prepareForRecycle`. So a recycled view can arrive here looking\n // like a crash. Measured on the close path and it did not fire, but\n // the call exists, and the generation gate is what keeps a stray one\n // from reloading against a page that is already gone.\n onRenderProcessGone: (event) => {\n if (generation === generationRef.current) {\n reload()\n }\n webviewProps?.onRenderProcessGone?.(event)\n },\n onContentProcessDidTerminate: (event) => {\n if (generation === generationRef.current) {\n reload()\n }\n webviewProps?.onContentProcessDidTerminate?.(event)\n },\n }}\n />\n </View>\n </View>\n </SafeAreaView>\n )\n})\nLorikeetWidgetView.displayName = 'LorikeetWidgetView'\n"],"mappings":"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"}
|