opendesk-react-native 0.1.3 → 0.1.7

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 CHANGED
@@ -47,6 +47,23 @@ Both adapters store the token as *after first unlock, this device only*. It's re
47
47
 
48
48
  Leave `storage` out and the token lives in memory only, so every launch starts a new visitor. Any `{ getItem, setItem, removeItem }` object also works. AsyncStorage fits that shape, but it writes plain text, so don't use it for this.
49
49
 
50
+ ## Reply sound
51
+
52
+ The SDK can play the web widget's reply chime when an agent or AI replies in a conversation the visitor doesn't have open. It follows the widget's **Sound** setting in openDesk, the same as on the web. Install `expo-audio` and pass it in:
53
+
54
+ ```tsx
55
+ // npx expo install expo-audio
56
+ import * as ExpoAudio from 'expo-audio';
57
+ import { expoAudioSound } from 'opendesk-react-native';
58
+
59
+ export const support = new OpenDesk({
60
+ // …
61
+ sound: expoAudioSound(ExpoAudio), // or expoAudioSound(ExpoAudio, { volume: 0.5 })
62
+ });
63
+ ```
64
+
65
+ The chime ships with the package (`assets/chime.wav`). The SDK leaves your app's audio mode alone; under expo-audio's default the chime mixes with other audio rather than pausing it. To play something else, pass any function as `sound`. Leave `sound` out for no sound.
66
+
50
67
  ## Identify the signed-in user
51
68
 
52
69
  To attach chats to a known contact, pass `{ user_id, user_hash, email, name }` as `identity`. `user_hash` is `hex(HMAC-SHA256(widget_secret, user_id))`, as described in [Identity verification](https://docs.opendesk.sh/guides/live-chat/identity-verification/). Compute it on your server, never in the app, and serve it from an authenticated endpoint of your own:
@@ -76,7 +93,6 @@ import { OpenDeskChat, OpenDeskLauncher } from 'opendesk-react-native';
76
93
 
77
94
  <OpenDeskChat
78
95
  onClose={() => navigation.goBack()}
79
- keyboardVerticalOffset={headerHeight}
80
96
  pickAttachment={async () => {
81
97
  const result = await DocumentPicker.getDocumentAsync({ type: ['image/*', 'application/pdf'] });
82
98
  const file = result.assets?.[0];
@@ -93,6 +109,14 @@ import { OpenDeskChat, OpenDeskLauncher } from 'opendesk-react-native';
93
109
  - `accentColor`
94
110
  - `strings`: replaces any of the built-in English copy (`defaultStrings` lists every key)
95
111
  - `initialConversationId`
112
+ - `keyboardVerticalOffset`: extra space between the composer and the keyboard (see below)
113
+
114
+ The chat keeps its composer above the keyboard by measuring where it sits on screen, so it needs no offset under a navigation header. Two cases it can't measure past:
115
+
116
+ - **React Native's `<Modal>` on Android** is a separate window that gets no keyboard events, so the composer stays under the keyboard. Show the chat as a screen, or as a full-screen view over your app, instead.
117
+ - **An iOS page sheet** (`presentationStyle="pageSheet"`, or a sheet-style navigation modal) starts below the top of the screen. Pass the sheet's distance from the top as `keyboardVerticalOffset`, or present it full screen.
118
+
119
+ Tapping an attached image opens it inside the chat. On Android, the back button closes it.
96
120
 
97
121
  For an in-app banner while the chat is closed, use `support.onMessage(({ conversationId, from, message }) => …)`.
98
122
 
Binary file
package/lib/api.js CHANGED
@@ -63,13 +63,14 @@ class OpenDeskApi {
63
63
  const form = new FormData();
64
64
  if (isBlob(file)) {
65
65
  form.append('file', file, 'name' in file && typeof file.name === 'string' ? file.name : 'upload');
66
+ return this.request('POST', '/v1/widget/attachments', form);
66
67
  }
67
- else {
68
- form.append('file', { uri: file.uri, name: file.name, type: file.type || 'application/octet-stream' });
69
- }
70
- return this.request('POST', '/v1/widget/attachments', form);
68
+ form.append('file', { uri: file.uri, name: file.name, type: file.type || 'application/octet-stream' });
69
+ // Expo replaces `fetch` with expo/fetch, which can't send `{ uri }` parts; React Native's
70
+ // XMLHttpRequest reads the file from disk itself.
71
+ return this.request('POST', '/v1/widget/attachments', form, true, typeof XMLHttpRequest === 'undefined' ? this.fetcher : xhrFetch);
71
72
  }
72
- async request(method, path, body, authenticated = true) {
73
+ async request(method, path, body, authenticated = true, fetcher = this.fetcher) {
73
74
  const headers = { Accept: 'application/json' };
74
75
  let payload;
75
76
  if (body instanceof FormData) {
@@ -84,10 +85,10 @@ class OpenDeskApi {
84
85
  }
85
86
  let response;
86
87
  try {
87
- response = await this.fetcher(this.baseUrl + path, { method, headers, body: payload });
88
+ response = await fetcher(this.baseUrl + path, { method, headers, body: payload });
88
89
  }
89
- catch {
90
- throw new errors_1.OpenDeskError(0, 'network_error', "Can't reach the server");
90
+ catch (cause) {
91
+ throw new errors_1.OpenDeskError(0, 'network_error', "Can't reach the server", cause);
91
92
  }
92
93
  if (!response.ok) {
93
94
  let code = 'error';
@@ -112,3 +113,19 @@ exports.OpenDeskApi = OpenDeskApi;
112
113
  function isBlob(file) {
113
114
  return typeof Blob !== 'undefined' && file instanceof Blob;
114
115
  }
116
+ /** `fetch` over XMLHttpRequest, for the multipart uploads expo/fetch can't send. */
117
+ const xhrFetch = (input, init) => new Promise((resolve, reject) => {
118
+ const xhr = new XMLHttpRequest();
119
+ xhr.open(init?.method ?? 'GET', String(input));
120
+ Object.entries((init?.headers ?? {})).forEach(([name, value]) => xhr.setRequestHeader(name, value));
121
+ xhr.onload = () => {
122
+ const empty = xhr.status === 204 || xhr.status === 205 || xhr.status === 304;
123
+ resolve(new Response(empty ? null : xhr.responseText, {
124
+ status: xhr.status,
125
+ headers: { 'content-type': xhr.getResponseHeader('content-type') ?? '' },
126
+ }));
127
+ };
128
+ xhr.onerror = () => reject(new TypeError('Network request failed'));
129
+ xhr.ontimeout = xhr.onerror;
130
+ xhr.send((init?.body ?? null));
131
+ });
package/lib/client.d.ts CHANGED
@@ -9,8 +9,14 @@ export interface OpenDeskOptions {
9
9
  widgetKey: string;
10
10
  /** Persists the visitor token so conversations survive restarts. Defaults to memory. */
11
11
  storage?: TokenStorage;
12
+ /** Replaces `fetch`. Native `{ uri }` uploads still go over XMLHttpRequest, which can read the file. */
12
13
  fetcher?: Fetcher;
13
14
  socketFactory?: SocketFactory;
15
+ /**
16
+ * Played when an agent or AI reply arrives in a conversation that isn't open, if the widget
17
+ * has sound on. `expoAudioSound(ExpoAudio)` plays the web widget's chime.
18
+ */
19
+ sound?: () => void;
14
20
  }
15
21
  /** An AI answer being streamed before it lands as a message. */
16
22
  export interface AIReply {
@@ -61,6 +67,7 @@ export declare class OpenDesk {
61
67
  private readonly storage;
62
68
  private readonly storageKey;
63
69
  private readonly socketFactory;
70
+ private readonly sound;
64
71
  private readonly subscribers;
65
72
  private readonly messageListeners;
66
73
  private readonly typingTimers;
@@ -103,6 +110,8 @@ export declare class OpenDesk {
103
110
  private authorized;
104
111
  private catchUp;
105
112
  private onRealtimeEvent;
113
+ /** The widget's sound setting decides, as on the web; the app decides what plays. */
114
+ private playSound;
106
115
  private onLiveEvent;
107
116
  private onAgentTyping;
108
117
  private clearTyping;
package/lib/client.js CHANGED
@@ -45,6 +45,7 @@ class OpenDesk {
45
45
  this.storage = options.storage ?? (0, storage_1.memoryStorage)();
46
46
  this.storageKey = `opendesk.visitor.${options.widgetKey}`;
47
47
  this.socketFactory = options.socketFactory ?? realtime_1.phoenixSocketFactory;
48
+ this.sound = options.sound ?? null;
48
49
  }
49
50
  /** Called for every agent or AI reply, e.g. to show an in-app banner. */
50
51
  onMessage(listener) {
@@ -290,6 +291,7 @@ class OpenDesk {
290
291
  this.set((state) => ({
291
292
  unread: { ...state.unread, [conversationId]: (state.unread[conversationId] ?? 0) + 1 },
292
293
  }));
294
+ this.playSound();
293
295
  }
294
296
  const from = message.author.user?.name || message.author.bot?.name || this.state.config?.workspace_name || 'Support';
295
297
  this.messageListeners.forEach((listener) => {
@@ -301,6 +303,18 @@ class OpenDesk {
301
303
  }
302
304
  });
303
305
  }
306
+ /** The widget's sound setting decides, as on the web; the app decides what plays. */
307
+ playSound() {
308
+ if (!this.sound || this.state.config?.sound !== true) {
309
+ return;
310
+ }
311
+ try {
312
+ this.sound();
313
+ }
314
+ catch {
315
+ // A sound that fails must not break the stream.
316
+ }
317
+ }
304
318
  onLiveEvent(event) {
305
319
  const { reply_id: replyId, ticket_id: conversationId } = event.data;
306
320
  if (typeof replyId !== 'string' || typeof conversationId !== 'string') {
package/lib/errors.d.ts CHANGED
@@ -1,8 +1,12 @@
1
- /** A failed openDesk request; `status` is 0 when the server could not be reached. */
1
+ /**
2
+ * A failed openDesk request; `status` is 0 when the server could not be reached, and then
3
+ * `cause` holds what `fetch` threw.
4
+ */
2
5
  export declare class OpenDeskError extends Error {
3
6
  readonly status: number;
4
7
  readonly code: string;
5
- constructor(status: number, code: string, message: string);
8
+ readonly cause?: unknown | undefined;
9
+ constructor(status: number, code: string, message: string, cause?: unknown | undefined);
6
10
  /** The visitor token is unknown or revoked; a fresh session is needed. */
7
11
  get isUnauthorized(): boolean;
8
12
  }
package/lib/errors.js CHANGED
@@ -1,12 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OpenDeskError = void 0;
4
- /** A failed openDesk request; `status` is 0 when the server could not be reached. */
4
+ /**
5
+ * A failed openDesk request; `status` is 0 when the server could not be reached, and then
6
+ * `cause` holds what `fetch` threw.
7
+ */
5
8
  class OpenDeskError extends Error {
6
- constructor(status, code, message) {
9
+ constructor(status, code, message, cause) {
7
10
  super(message);
8
11
  this.status = status;
9
12
  this.code = code;
13
+ this.cause = cause;
10
14
  this.name = 'OpenDeskError';
11
15
  }
12
16
  /** The visitor token is unknown or revoked; a fresh session is needed. */
package/lib/index.d.ts CHANGED
@@ -7,6 +7,8 @@ export { OpenDeskRealtime, phoenixSocketFactory } from './realtime';
7
7
  export type { RealtimeChannel, RealtimeSocket, SocketFactory } from './realtime';
8
8
  export { keychainStorage, memoryStorage, secureStoreStorage } from './storage';
9
9
  export type { ExpoSecureStoreModule, KeychainModule, TokenStorage } from './storage';
10
+ export { expoAudioSound } from './sound';
11
+ export type { ExpoAudioModule, ExpoAudioPlayer } from './sound';
10
12
  export { OpenDeskProvider, useConversation, useConversations, useOpenDesk, useOpenDeskState, useUnreadCount, } from './react';
11
13
  export type { ConversationView, OpenDeskProviderProps } from './react';
12
14
  export { OpenDeskChat } from './ui/OpenDeskChat';
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.defaultStrings = exports.OpenDeskLauncher = exports.OpenDeskChat = exports.useUnreadCount = exports.useOpenDeskState = exports.useOpenDesk = exports.useConversations = exports.useConversation = exports.OpenDeskProvider = exports.secureStoreStorage = exports.memoryStorage = exports.keychainStorage = exports.phoenixSocketFactory = exports.OpenDeskRealtime = exports.OpenDeskError = exports.OpenDeskApi = exports.OpenDesk = void 0;
3
+ exports.defaultStrings = exports.OpenDeskLauncher = exports.OpenDeskChat = exports.useUnreadCount = exports.useOpenDeskState = exports.useOpenDesk = exports.useConversations = exports.useConversation = exports.OpenDeskProvider = exports.expoAudioSound = exports.secureStoreStorage = exports.memoryStorage = exports.keychainStorage = exports.phoenixSocketFactory = exports.OpenDeskRealtime = exports.OpenDeskError = exports.OpenDeskApi = exports.OpenDesk = void 0;
4
4
  var client_1 = require("./client");
5
5
  Object.defineProperty(exports, "OpenDesk", { enumerable: true, get: function () { return client_1.OpenDesk; } });
6
6
  var api_1 = require("./api");
@@ -14,6 +14,8 @@ var storage_1 = require("./storage");
14
14
  Object.defineProperty(exports, "keychainStorage", { enumerable: true, get: function () { return storage_1.keychainStorage; } });
15
15
  Object.defineProperty(exports, "memoryStorage", { enumerable: true, get: function () { return storage_1.memoryStorage; } });
16
16
  Object.defineProperty(exports, "secureStoreStorage", { enumerable: true, get: function () { return storage_1.secureStoreStorage; } });
17
+ var sound_1 = require("./sound");
18
+ Object.defineProperty(exports, "expoAudioSound", { enumerable: true, get: function () { return sound_1.expoAudioSound; } });
17
19
  var react_1 = require("./react");
18
20
  Object.defineProperty(exports, "OpenDeskProvider", { enumerable: true, get: function () { return react_1.OpenDeskProvider; } });
19
21
  Object.defineProperty(exports, "useConversation", { enumerable: true, get: function () { return react_1.useConversation; } });
package/lib/sound.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ /** The parts of `expo-audio` the adapter uses; pass the module itself. */
2
+ export interface ExpoAudioModule {
3
+ createAudioPlayer(source: number): ExpoAudioPlayer;
4
+ }
5
+ export interface ExpoAudioPlayer {
6
+ volume: number;
7
+ play(): void;
8
+ seekTo(seconds: number): Promise<void>;
9
+ }
10
+ /**
11
+ * Plays the web widget's reply chime with `expo-audio`. Takes the module as an argument, so the
12
+ * SDK has no dependency on Expo:
13
+ *
14
+ * import * as ExpoAudio from 'expo-audio';
15
+ * new OpenDesk({ …, sound: expoAudioSound(ExpoAudio) });
16
+ *
17
+ * The player is made on the first reply and kept for the next. The app's audio mode is left
18
+ * alone; under expo-audio's default the chime mixes with other audio instead of pausing it.
19
+ */
20
+ export declare function expoAudioSound(ExpoAudio: ExpoAudioModule, options?: {
21
+ volume?: number;
22
+ }): () => void;
package/lib/sound.js ADDED
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.expoAudioSound = expoAudioSound;
7
+ const chime_wav_1 = __importDefault(require("../assets/chime.wav"));
8
+ /**
9
+ * Plays the web widget's reply chime with `expo-audio`. Takes the module as an argument, so the
10
+ * SDK has no dependency on Expo:
11
+ *
12
+ * import * as ExpoAudio from 'expo-audio';
13
+ * new OpenDesk({ …, sound: expoAudioSound(ExpoAudio) });
14
+ *
15
+ * The player is made on the first reply and kept for the next. The app's audio mode is left
16
+ * alone; under expo-audio's default the chime mixes with other audio instead of pausing it.
17
+ */
18
+ function expoAudioSound(ExpoAudio, options = {}) {
19
+ let player = null;
20
+ return () => {
21
+ if (!player) {
22
+ player = ExpoAudio.createAudioPlayer(chime_wav_1.default);
23
+ if (options.volume !== undefined) {
24
+ player.volume = options.volume;
25
+ }
26
+ }
27
+ const current = player;
28
+ void current
29
+ .seekTo(0)
30
+ .catch(() => undefined)
31
+ .then(() => current.play());
32
+ };
33
+ }
package/lib/types.d.ts CHANGED
@@ -20,6 +20,8 @@ export interface WidgetConfig {
20
20
  locale: string;
21
21
  language: string;
22
22
  open: boolean;
23
+ /** Whether replies play a sound while the visitor isn't looking at them. */
24
+ sound: boolean;
23
25
  require_email: boolean;
24
26
  chat_welcome: boolean;
25
27
  help_center_url: string;
@@ -17,7 +17,11 @@ export interface OpenDeskChatProps {
17
17
  theme?: 'light' | 'dark' | 'auto';
18
18
  /** Replaces any of the built-in English strings. */
19
19
  strings?: Partial<ChatStrings>;
20
- /** Height of any navigation header above the chat, so the keyboard never covers the composer on iOS. */
20
+ /**
21
+ * Extra space to keep between the composer and the keyboard. The chat measures its own position,
22
+ * so navigation headers need nothing here; inside an iOS page sheet, which it can't see past,
23
+ * pass the sheet's distance from the top of the screen.
24
+ */
21
25
  keyboardVerticalOffset?: number;
22
26
  }
23
27
  /**
@@ -7,6 +7,9 @@ const react_native_1 = require("react-native");
7
7
  const react_2 = require("../react");
8
8
  const BotFace_1 = require("./BotFace");
9
9
  const icons_1 = require("./icons");
10
+ const keyboard_1 = require("./keyboard");
11
+ const rich_1 = require("./rich");
12
+ const RichText_1 = require("./RichText");
10
13
  const strings_1 = require("./strings");
11
14
  const theme_1 = require("./theme");
12
15
  const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
@@ -26,8 +29,10 @@ function OpenDeskChat({ initialConversationId, onClose, pickAttachment, accentCo
26
29
  const strings = (0, react_1.useMemo)(() => ({ ...strings_1.defaultStrings, ...stringOverrides }), [stringOverrides]);
27
30
  const styles = (0, react_1.useMemo)(() => createStyles(theme), [theme]);
28
31
  const ui = (0, react_1.useMemo)(() => ({ theme, strings, styles, bot: config?.bot ?? null }), [theme, strings, styles, config?.bot]);
32
+ const rootRef = (0, react_1.useRef)(null);
33
+ const keyboard = (0, keyboard_1.useKeyboardInset)(rootRef, keyboardVerticalOffset);
29
34
  const inThread = conversationId !== null || composing;
30
- return ((0, jsx_runtime_1.jsx)(react_native_1.KeyboardAvoidingView, { style: styles.root, behavior: react_native_1.Platform.OS === 'ios' ? 'padding' : undefined, keyboardVerticalOffset: keyboardVerticalOffset, children: inThread ? ((0, jsx_runtime_1.jsx)(Thread, { ui: ui, conversationId: conversationId, pickAttachment: pickAttachment, onBack: () => {
35
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { ref: rootRef, style: [styles.root, { paddingBottom: keyboard.inset }], onLayout: keyboard.onLayout, children: inThread ? ((0, jsx_runtime_1.jsx)(Thread, { ui: ui, conversationId: conversationId, pickAttachment: pickAttachment, onBack: () => {
31
36
  setConversationId(null);
32
37
  setComposing(false);
33
38
  }, onClose: onClose, onStarted: (id) => {
@@ -67,6 +72,12 @@ function Thread({ ui, conversationId, pickAttachment, onBack, onClose, onStarted
67
72
  const [focused, setFocused] = (0, react_1.useState)(false);
68
73
  const [inputHeight, setInputHeight] = (0, react_1.useState)(30);
69
74
  const [error, setError] = (0, react_1.useState)(null);
75
+ const [viewing, setViewing] = (0, react_1.useState)(null);
76
+ const openImage = (0, react_1.useCallback)((attachment) => {
77
+ react_native_1.Keyboard.dismiss();
78
+ setViewing(attachment);
79
+ }, []);
80
+ const closeImage = (0, react_1.useCallback)(() => setViewing(null), []);
70
81
  const topics = config?.topics ?? [];
71
82
  const topic = topics.find((t) => t.id === topicId) ?? null;
72
83
  const needsTopic = conversationId === null && topics.length > 0 && topic === null;
@@ -160,14 +171,14 @@ function Thread({ ui, conversationId, pickAttachment, onBack, onClose, onStarted
160
171
  }, [client, config?.attachment_extensions, pickAttachment, strings]);
161
172
  const showAttach = Boolean(pickAttachment) && config?.attachments !== false && (config?.attachment_extensions?.length ?? 1) > 0;
162
173
  const bot = config?.ai_answers ? config.bot : null;
163
- return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.flex, children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.head, children: [(0, jsx_runtime_1.jsx)(IconButton, { ui: ui, label: strings.back, onPress: onBack, icon: (0, jsx_runtime_1.jsx)(icons_1.ChevronIcon, { color: theme.fg3, direction: "left" }) }), bot ? ((0, jsx_runtime_1.jsx)(BotFace_1.BotFace, { size: 26, look: bot.look, avatarUrl: bot.avatar_url })) : ((0, jsx_runtime_1.jsx)(Glyph, { ui: ui, name: name, url: config?.avatar_url ?? null })), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.headMain, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.title, numberOfLines: 1, children: conversation?.subject || name }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.titleSub, numberOfLines: 1, children: subtitleFor(config, strings) })] }), onClose ? (0, jsx_runtime_1.jsx)(IconButton, { ui: ui, label: strings.closeChat, onPress: onClose, icon: (0, jsx_runtime_1.jsx)(icons_1.CloseIcon, { color: theme.fg3 }) }) : null] }), rows.length === 0 ? ((0, jsx_runtime_1.jsx)(react_native_1.ScrollView, { style: styles.flex, contentContainerStyle: styles.emptyScroll, keyboardShouldPersistTaps: "handled", children: needsTopic ? ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.card, styles.picker], children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.pickerTitle, children: strings.topicsTitle }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.pickerHint, children: strings.topicsHint }), topics.map((item, index) => ((0, jsx_runtime_1.jsxs)(react_native_1.Pressable, { accessibilityRole: "button", onPress: () => setTopicId(item.id), style: ({ pressed }) => [styles.topic, index > 0 && styles.convBorder, pressed && { backgroundColor: theme.bg2 }], children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.topicText, children: item.label }), (0, jsx_runtime_1.jsx)(icons_1.ChevronIcon, { color: theme.fg4, size: 14, direction: "right" })] }, item.id)))] })) : ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.empty, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.emptyTitle, children: config?.greeting || strings.greeting }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.emptyText, children: config?.intro || strings.emptyHint })] })) })) : ((0, jsx_runtime_1.jsx)(react_native_1.FlatList, { inverted: true, style: styles.flex, data: rows, keyExtractor: (row) => row.key, contentContainerStyle: styles.thread, keyboardShouldPersistTaps: "handled", renderItem: ({ item }) => ((0, jsx_runtime_1.jsx)(ThreadRow, { ui: ui, row: item, showHandoff: item.kind === 'message' && item.message.id === handoffMessageId, onDecide: decide, onRequestHuman: requestHuman })) })), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.composer, children: [topic && conversationId === null ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.topicRow, children: (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.topicChip, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.topicChipText, numberOfLines: 1, children: topic.label }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "button", accessibilityLabel: strings.changeTopic, onPress: () => setTopicId(null), style: styles.topicChipX, children: (0, jsx_runtime_1.jsx)(icons_1.CloseIcon, { color: theme.fg3, size: 10 }) })] }) })) : null, attachments.length > 0 || uploadingName ? ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.pendingFiles, children: [attachments.map((attachment) => ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.chip, styles.pendingChip], children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.chipText, numberOfLines: 1, children: attachment.filename }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "button", accessibilityLabel: (0, strings_1.format)(strings.removeFile, { name: attachment.filename }), onPress: () => setAttachments((current) => current.filter((a) => a.id !== attachment.id)), hitSlop: 8, children: (0, jsx_runtime_1.jsx)(icons_1.CloseIcon, { color: theme.fg3, size: 10 }) })] }, attachment.id))), uploadingName ? ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.chip, styles.pendingChip], children: [(0, jsx_runtime_1.jsx)(react_native_1.ActivityIndicator, { size: "small", color: theme.fg3, style: styles.chipSpinner }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.chipText, numberOfLines: 1, children: uploadingName })] })) : null] })) : null, error ? (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.err, children: error }) : null, (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.cbar, showAttach && styles.cbarLead, focused && styles.cbarFocus, needsTopic && styles.cbarOff], children: [showAttach ? ((0, jsx_runtime_1.jsx)(IconButton, { ui: ui, label: strings.attach, onPress: attach, disabled: uploadingName !== null || needsTopic, icon: (0, jsx_runtime_1.jsx)(icons_1.PaperclipIcon, { color: theme.fg3 }) })) : null, (0, jsx_runtime_1.jsx)(react_native_1.TextInput, { style: [styles.input, { height: inputHeight }], value: draft, onContentSizeChange: (event) => setInputHeight(Math.min(120, Math.max(30, Math.ceil(event.nativeEvent.contentSize.height)))), ...(react_native_1.Platform.OS === 'web' ? { rows: 1 } : {}), editable: !needsTopic, onFocus: () => setFocused(true), onBlur: () => setFocused(false), onChangeText: (text) => {
174
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.flex, children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.head, children: [(0, jsx_runtime_1.jsx)(IconButton, { ui: ui, label: strings.back, onPress: onBack, icon: (0, jsx_runtime_1.jsx)(icons_1.ChevronIcon, { color: theme.fg3, direction: "left" }) }), bot ? ((0, jsx_runtime_1.jsx)(BotFace_1.BotFace, { size: 26, look: bot.look, avatarUrl: bot.avatar_url })) : ((0, jsx_runtime_1.jsx)(Glyph, { ui: ui, name: name, url: config?.avatar_url ?? null })), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.headMain, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.title, numberOfLines: 1, children: conversation?.subject || name }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.titleSub, numberOfLines: 1, children: subtitleFor(config, strings) })] }), onClose ? (0, jsx_runtime_1.jsx)(IconButton, { ui: ui, label: strings.closeChat, onPress: onClose, icon: (0, jsx_runtime_1.jsx)(icons_1.CloseIcon, { color: theme.fg3 }) }) : null] }), rows.length === 0 ? ((0, jsx_runtime_1.jsx)(react_native_1.ScrollView, { style: styles.flex, contentContainerStyle: styles.emptyScroll, keyboardShouldPersistTaps: "handled", children: needsTopic ? ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.card, styles.picker], children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.pickerTitle, children: strings.topicsTitle }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.pickerHint, children: strings.topicsHint }), topics.map((item, index) => ((0, jsx_runtime_1.jsxs)(react_native_1.Pressable, { accessibilityRole: "button", onPress: () => setTopicId(item.id), style: ({ pressed }) => [styles.topic, index > 0 && styles.convBorder, pressed && { backgroundColor: theme.bg2 }], children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.topicText, children: item.label }), (0, jsx_runtime_1.jsx)(icons_1.ChevronIcon, { color: theme.fg4, size: 14, direction: "right" })] }, item.id)))] })) : ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.empty, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.emptyTitle, children: config?.greeting || strings.greeting }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.emptyText, children: config?.intro || strings.emptyHint })] })) })) : ((0, jsx_runtime_1.jsx)(react_native_1.FlatList, { inverted: true, style: styles.flex, data: rows, keyExtractor: (row) => row.key, contentContainerStyle: styles.thread, keyboardShouldPersistTaps: "handled", renderItem: ({ item }) => ((0, jsx_runtime_1.jsx)(ThreadRow, { ui: ui, row: item, showHandoff: item.kind === 'message' && item.message.id === handoffMessageId, onDecide: decide, onRequestHuman: requestHuman, onOpenImage: openImage })) })), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.composer, children: [topic && conversationId === null ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.topicRow, children: (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.topicChip, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.topicChipText, numberOfLines: 1, children: topic.label }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "button", accessibilityLabel: strings.changeTopic, onPress: () => setTopicId(null), style: styles.topicChipX, children: (0, jsx_runtime_1.jsx)(icons_1.CloseIcon, { color: theme.fg3, size: 10 }) })] }) })) : null, attachments.length > 0 || uploadingName ? ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.pendingFiles, children: [attachments.map((attachment) => ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.chip, styles.pendingChip], children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.chipText, numberOfLines: 1, children: attachment.filename }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "button", accessibilityLabel: (0, strings_1.format)(strings.removeFile, { name: attachment.filename }), onPress: () => setAttachments((current) => current.filter((a) => a.id !== attachment.id)), hitSlop: 8, children: (0, jsx_runtime_1.jsx)(icons_1.CloseIcon, { color: theme.fg3, size: 10 }) })] }, attachment.id))), uploadingName ? ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.chip, styles.pendingChip], children: [(0, jsx_runtime_1.jsx)(react_native_1.ActivityIndicator, { size: "small", color: theme.fg3, style: styles.chipSpinner }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.chipText, numberOfLines: 1, children: uploadingName })] })) : null] })) : null, error ? (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.err, children: error }) : null, (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.cbar, showAttach && styles.cbarLead, focused && styles.cbarFocus, needsTopic && styles.cbarOff], children: [showAttach ? ((0, jsx_runtime_1.jsx)(IconButton, { ui: ui, label: strings.attach, onPress: attach, disabled: uploadingName !== null || needsTopic, icon: (0, jsx_runtime_1.jsx)(icons_1.PaperclipIcon, { color: theme.fg3 }) })) : null, (0, jsx_runtime_1.jsx)(react_native_1.TextInput, { style: [styles.input, { height: inputHeight }], value: draft, onContentSizeChange: (event) => setInputHeight(Math.min(120, Math.max(30, Math.ceil(event.nativeEvent.contentSize.height)))), ...(react_native_1.Platform.OS === 'web' ? { rows: 1 } : {}), editable: !needsTopic, onFocus: () => setFocused(true), onBlur: () => setFocused(false), onChangeText: (text) => {
164
175
  setDraft(text);
165
176
  if (conversationId) {
166
177
  client.typing(conversationId);
167
178
  }
168
- }, placeholder: needsTopic ? strings.placeholderTopic : strings.placeholder, placeholderTextColor: theme.fg4, accessibilityLabel: strings.placeholder, multiline: true }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "button", accessibilityLabel: strings.send, disabled: !ready, onPress: send, style: [styles.send, ready && styles.sendReady], children: (0, jsx_runtime_1.jsx)(icons_1.ArrowIcon, { color: theme.accentFg, size: 14, angle: -90 }) })] })] })] }));
179
+ }, placeholder: needsTopic ? strings.placeholderTopic : strings.placeholder, placeholderTextColor: theme.fg4, accessibilityLabel: strings.placeholder, multiline: true }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "button", accessibilityLabel: strings.send, disabled: !ready, onPress: send, style: [styles.send, ready && styles.sendReady], children: (0, jsx_runtime_1.jsx)(icons_1.ArrowIcon, { color: theme.accentFg, size: 14, angle: -90 }) })] })] }), viewing ? (0, jsx_runtime_1.jsx)(ImageViewer, { ui: ui, attachment: viewing, onClose: closeImage }) : null] }));
169
180
  }
170
- const ThreadRow = (0, react_1.memo)(function ThreadRow({ ui, row, showHandoff, onDecide, onRequestHuman, }) {
181
+ const ThreadRow = (0, react_1.memo)(function ThreadRow({ ui, row, showHandoff, onDecide, onRequestHuman, onOpenImage, }) {
171
182
  const { theme, strings, styles } = ui;
172
183
  switch (row.kind) {
173
184
  case 'day':
@@ -177,16 +188,16 @@ const ThreadRow = (0, react_1.memo)(function ThreadRow({ ui, row, showHandoff, o
177
188
  case 'handed':
178
189
  return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.handed, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.handedIc, children: (0, jsx_runtime_1.jsx)(icons_1.PersonIcon, { color: theme.fg2 }) }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.flex, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.handedTitle, children: strings.handedOff }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.handedText, children: strings.handedOffHint })] })] }));
179
190
  case 'reply':
180
- return (0, jsx_runtime_1.jsx)(Bubble, { ui: ui, reply: row.reply, first: true, last: true, gap: true, showHandoff: false, onDecide: onDecide, onRequestHuman: onRequestHuman });
191
+ return ((0, jsx_runtime_1.jsx)(Bubble, { ui: ui, reply: row.reply, first: true, last: true, gap: true, showHandoff: false, onDecide: onDecide, onRequestHuman: onRequestHuman, onOpenImage: onOpenImage }));
181
192
  case 'message':
182
- return ((0, jsx_runtime_1.jsx)(Bubble, { ui: ui, message: row.message, first: row.first, last: row.last, gap: row.gap, showHandoff: showHandoff, onDecide: onDecide, onRequestHuman: onRequestHuman }));
193
+ return ((0, jsx_runtime_1.jsx)(Bubble, { ui: ui, message: row.message, first: row.first, last: row.last, gap: row.gap, showHandoff: showHandoff, onDecide: onDecide, onRequestHuman: onRequestHuman, onOpenImage: onOpenImage }));
183
194
  }
184
195
  });
185
196
  /**
186
197
  * One bubble for a stored message or an AI answer still streaming. Both share one element
187
198
  * tree, so when the stored message replaces the stream (same row key) nothing remounts.
188
199
  */
189
- function Bubble({ ui, message, reply, first, last, gap, showHandoff, onDecide, onRequestHuman, }) {
200
+ function Bubble({ ui, message, reply, first, last, gap, showHandoff, onDecide, onRequestHuman, onOpenImage, }) {
190
201
  const { theme, strings, styles, bot: fallbackBot } = ui;
191
202
  const mine = message?.direction === 'inbound';
192
203
  const ai = reply !== undefined || message?.ai === true;
@@ -195,6 +206,7 @@ function Bubble({ ui, message, reply, first, last, gap, showHandoff, onDecide, o
195
206
  const avatar = message ? (message.author.user?.avatar_url ?? bot?.avatar_url) : bot?.avatar_url;
196
207
  const botLook = bot?.look ?? fallbackBot?.look;
197
208
  const text = message ? (message.body_text ?? '') : (reply?.text ?? '');
209
+ const body = (0, react_1.useMemo)(() => bodyOf(message, reply, mine, ai), [message, reply, mine, ai]);
198
210
  const local = message?.id.startsWith('local-') === true;
199
211
  const action = message?.ai_action;
200
212
  const tools = reply?.tools ?? [];
@@ -205,12 +217,29 @@ function Bubble({ ui, message, reply, first, last, gap, showHandoff, onDecide, o
205
217
  const files = (message?.attachments ?? []).filter((a) => !a.content_type.startsWith('image/'));
206
218
  return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.row, mine ? styles.rowMe : styles.rowThem, gap && styles.rowGap], children: [!mine && first && author ? ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.who, children: [ai ? ((0, jsx_runtime_1.jsx)(BotFace_1.BotFace, { size: 18, look: botLook, avatarUrl: avatar })) : avatar ? ((0, jsx_runtime_1.jsx)(Avatar, { ui: ui, size: 18, name: author, url: avatar, tint: theme.bg2 })) : null, (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.whoName, children: author }), ai ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.aiPill, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.aiPillText, children: strings.ai }) })) : null] })) : null, tools.length > 0 ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.tools, children: tools.map((tool, index) => ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.tool, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.toolIc, tool.status === 'error' && { backgroundColor: theme.bg2 }], children: (0, jsx_runtime_1.jsx)(icons_1.SparkIcon, { color: tool.status === 'error' ? theme.fg3 : theme.ai, size: 8 }) }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.toolText, tool.status === 'running' && { color: theme.fg2 }], children: (0, strings_1.format)(tool.status === 'running' ? strings.checking : tool.status === 'error' ? strings.toolFailed : strings.checked, {
207
219
  tool: tool.name.replace(/_/g, ' '),
208
- }) })] }, `${tool.name}-${index}`))) })) : null, text || reply ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
220
+ }) })] }, `${tool.name}-${index}`))) })) : null, text || body.blocks.length > 0 || reply ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
209
221
  styles.msg,
210
222
  mine ? styles.msgMe : ai ? styles.msgAi : styles.msgThem,
211
223
  corners,
212
224
  local && styles.sendingMsg,
213
- ], children: text ? (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.msgText, mine && { color: theme.accentFg }], children: text }) : (0, jsx_runtime_1.jsx)(Dots, { color: theme.ai }) })) : null, images.map((attachment) => ((0, jsx_runtime_1.jsx)(react_native_1.Pressable, { onPress: () => react_native_1.Linking.openURL(attachment.url), style: styles.fileGap, children: (0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri: attachment.url }, style: styles.thumb, accessibilityLabel: attachment.filename, resizeMode: "cover" }) }, attachment.id))), files.map((attachment) => ((0, jsx_runtime_1.jsxs)(react_native_1.Pressable, { onPress: () => react_native_1.Linking.openURL(attachment.url), style: [styles.chip, styles.fileGap], children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.chipText, numberOfLines: 1, children: attachment.filename }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.chipSize, children: fileSize(attachment.size_bytes) })] }, attachment.id))), message && action ? (0, jsx_runtime_1.jsx)(ActionCard, { ui: ui, message: message, onDecide: onDecide }) : null, message && showHandoff ? ((0, jsx_runtime_1.jsxs)(react_native_1.Pressable, { accessibilityRole: "button", onPress: () => onRequestHuman(message), style: ({ pressed }) => [styles.handoff, pressed && { backgroundColor: theme.bg2 }], children: [(0, jsx_runtime_1.jsx)(icons_1.PersonIcon, { color: theme.fg, size: 14 }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.handoffText, children: strings.talkToPerson })] })) : null, last && message ? (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.meta, children: local ? strings.sending : clockTime(message.created_at) }) : null] }));
225
+ ], children: mine ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.msgText, { color: theme.accentFg }], children: text })) : body.blocks.length > 0 ? ((0, jsx_runtime_1.jsx)(RichText_1.RichText, { blocks: body.blocks, textStyle: styles.msgText, codeBackground: ai ? theme.bg2 : theme.line, quoteColor: theme.line2, mono: theme.mono })) : ((0, jsx_runtime_1.jsx)(Dots, { color: theme.ai })) })) : null, body.sources.length > 0 ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.sources, children: body.sources.map((source) => ((0, jsx_runtime_1.jsxs)(react_native_1.Pressable, { accessibilityRole: "link", accessibilityHint: (0, rich_1.hostOf)(source.href), onPress: () => {
226
+ react_native_1.Linking.openURL(source.href).catch(() => undefined);
227
+ }, style: ({ pressed }) => [styles.src, pressed && { borderColor: theme.aiLine, backgroundColor: theme.aiSoft }], children: [(0, jsx_runtime_1.jsx)(icons_1.ArrowIcon, { color: theme.ai, size: 11, angle: -45 }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.srcText, numberOfLines: 1, children: source.title })] }, source.href))) })) : null, images.map((attachment) => ((0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "imagebutton", accessibilityLabel: (0, strings_1.format)(strings.viewImage, { name: attachment.filename }), onPress: () => onOpenImage(attachment), style: ({ pressed }) => [styles.fileGap, pressed && styles.pressed], children: (0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri: attachment.url }, style: styles.thumb, resizeMode: "cover" }) }, attachment.id))), files.map((attachment) => ((0, jsx_runtime_1.jsxs)(react_native_1.Pressable, { onPress: () => react_native_1.Linking.openURL(attachment.url), style: [styles.chip, styles.fileGap], children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.chipText, numberOfLines: 1, children: attachment.filename }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.chipSize, children: fileSize(attachment.size_bytes) })] }, attachment.id))), message && action ? (0, jsx_runtime_1.jsx)(ActionCard, { ui: ui, message: message, onDecide: onDecide }) : null, message && showHandoff ? ((0, jsx_runtime_1.jsxs)(react_native_1.Pressable, { accessibilityRole: "button", onPress: () => onRequestHuman(message), style: ({ pressed }) => [styles.handoff, pressed && { backgroundColor: theme.bg2 }], children: [(0, jsx_runtime_1.jsx)(icons_1.PersonIcon, { color: theme.fg, size: 14 }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.handoffText, children: strings.talkToPerson })] })) : null, last && message ? (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.meta, children: local ? strings.sending : clockTime(message.created_at) }) : null] }));
228
+ }
229
+ /**
230
+ * What a bubble shows: the visitor's own text as typed; stored replies from their HTML (links
231
+ * included), falling back to markdown in the text; AI answers still streaming as markdown. An AI
232
+ * answer's trailing list of links becomes its sources.
233
+ */
234
+ function bodyOf(message, reply, mine, ai) {
235
+ if (mine) {
236
+ return { blocks: [], sources: [] };
237
+ }
238
+ if (reply && !message) {
239
+ return { blocks: (0, rich_1.parseMarkdown)(reply.text, true), sources: [] };
240
+ }
241
+ const blocks = message?.body_html?.trim() ? (0, rich_1.parseHtml)(message.body_html, ai) : (0, rich_1.parseMarkdown)(message?.body_text ?? '');
242
+ return ai ? (0, rich_1.extractSources)(blocks) : { blocks, sources: [] };
214
243
  }
215
244
  function ActionCard({ ui, message, onDecide, }) {
216
245
  const { theme, strings, styles } = ui;
@@ -225,6 +254,21 @@ function ActionCard({ ui, message, onDecide, }) {
225
254
  }[action.status];
226
255
  return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.action, pending && styles.actionPending], children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.actionTop, children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.actionTool, children: [(0, jsx_runtime_1.jsx)(icons_1.SparkIcon, { color: theme.ai, size: 8 }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.actionToolText, numberOfLines: 1, children: action.tool.replace(/_/g, ' ') })] }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.actionNeed, action.status === 'failed' && { color: theme.danger }], children: label })] }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.actionTitle, children: action.title }), action.summary ? (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.actionSum, children: action.summary }) : null, pending ? ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.actionBtns, children: [(0, jsx_runtime_1.jsx)(react_native_1.Pressable, { onPress: () => onDecide(message, 'decline'), style: [styles.btn, styles.btnGhost], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.btnGhostText, children: strings.cancel }) }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { onPress: () => onDecide(message, 'confirm'), style: [styles.btn, styles.btnSolid], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.btnSolidText, children: strings.confirm }) })] })) : null] }));
227
256
  }
257
+ /** An attached image shown full size over the thread, so it opens in the chat instead of a browser. */
258
+ function ImageViewer({ ui, attachment, onClose }) {
259
+ const { strings, styles } = ui;
260
+ (0, react_1.useEffect)(() => {
261
+ if (react_native_1.Platform.OS !== 'android') {
262
+ return;
263
+ }
264
+ const subscription = react_native_1.BackHandler.addEventListener('hardwareBackPress', () => {
265
+ onClose();
266
+ return true;
267
+ });
268
+ return () => subscription.remove();
269
+ }, [onClose]);
270
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.viewer, accessibilityViewIsModal: true, children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.viewerHead, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.viewerName, numberOfLines: 1, children: attachment.filename }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "button", accessibilityLabel: strings.closeImage, onPress: onClose, hitSlop: 6, style: ({ pressed }) => [styles.iconBtn, pressed && styles.viewerPressed], children: (0, jsx_runtime_1.jsx)(icons_1.CloseIcon, { color: "#ffffff" }) })] }), (0, jsx_runtime_1.jsx)(react_native_1.ScrollView, { style: styles.flex, contentContainerStyle: styles.viewerBody, maximumZoomScale: 4, minimumZoomScale: 1, centerContent: true, showsHorizontalScrollIndicator: false, showsVerticalScrollIndicator: false, children: (0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri: attachment.url }, style: styles.viewerImage, resizeMode: "contain", accessibilityLabel: attachment.filename }) })] }));
271
+ }
228
272
  function IconButton({ ui, label, onPress, icon, disabled, }) {
229
273
  return ((0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "button", accessibilityLabel: label, onPress: onPress, disabled: disabled, hitSlop: 6, style: ({ pressed }) => [ui.styles.iconBtn, pressed && { backgroundColor: ui.theme.bg2 }, disabled && ui.styles.disabled], children: icon }));
230
274
  }
@@ -377,13 +421,13 @@ function createStyles(t) {
377
421
  alignItems: 'center',
378
422
  gap: 10,
379
423
  paddingHorizontal: 12,
380
- paddingTop: 12,
381
- paddingBottom: 11,
424
+ paddingTop: 18,
425
+ paddingBottom: 14,
382
426
  borderBottomWidth: 1,
383
427
  borderBottomColor: t.line,
384
428
  backgroundColor: t.bg,
385
429
  },
386
- headHome: { flexDirection: 'column', alignItems: 'stretch', gap: 0, padding: 16, paddingBottom: 20, backgroundColor: t.headBg },
430
+ headHome: { flexDirection: 'column', alignItems: 'stretch', gap: 0, padding: 16, paddingTop: 22, paddingBottom: 20, backgroundColor: t.headBg },
387
431
  headRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
388
432
  headMain: { flex: 1, minWidth: 0 },
389
433
  ws: { flexDirection: 'row', alignItems: 'center', gap: 9, flexShrink: 1 },
@@ -480,6 +524,21 @@ function createStyles(t) {
480
524
  msgMe: { backgroundColor: t.accent },
481
525
  msgThem: { backgroundColor: t.bg2 },
482
526
  msgAi: { backgroundColor: t.bg3, borderWidth: 1, borderColor: t.line },
527
+ sources: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 6 },
528
+ src: {
529
+ flexDirection: 'row',
530
+ alignItems: 'center',
531
+ gap: 6,
532
+ maxWidth: 240,
533
+ height: 26,
534
+ paddingLeft: 8,
535
+ paddingRight: 10,
536
+ borderRadius: r.ctl,
537
+ borderWidth: 1,
538
+ borderColor: t.line,
539
+ backgroundColor: t.bg,
540
+ },
541
+ srcText: { flexShrink: 1, fontSize: 12, color: t.fg2 },
483
542
  msgText: { fontSize: 13.5, lineHeight: 19.5, color: t.fg },
484
543
  sendingMsg: { opacity: 0.7 },
485
544
  meta: { fontFamily: t.mono, fontSize: 10, color: t.fg4, marginTop: 3, marginHorizontal: 4 },
@@ -489,6 +548,12 @@ function createStyles(t) {
489
548
  toolText: { fontSize: 12, lineHeight: 18, color: t.fg3 },
490
549
  fileGap: { marginTop: 4 },
491
550
  thumb: { width: 220, height: 160, borderRadius: r.card, borderWidth: 1, borderColor: t.line, backgroundColor: t.bg2 },
551
+ viewer: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, zIndex: 10, elevation: 10, backgroundColor: '#000000' },
552
+ viewerHead: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 12, paddingTop: 18, paddingBottom: 14 },
553
+ viewerName: { flex: 1, fontSize: 14, fontWeight: '600', color: '#ffffff' },
554
+ viewerPressed: { backgroundColor: 'rgba(255,255,255,0.16)' },
555
+ viewerBody: { flexGrow: 1 },
556
+ viewerImage: { flex: 1, width: '100%' },
492
557
  chip: {
493
558
  flexDirection: 'row',
494
559
  alignItems: 'center',
@@ -0,0 +1,15 @@
1
+ import { type TextStyle } from 'react-native';
2
+ import type { Block } from './rich';
3
+ interface RichTextProps {
4
+ blocks: Block[];
5
+ /** The bubble's text style; colour and size carry into every block. */
6
+ textStyle: TextStyle;
7
+ /** Fill behind inline code and code blocks. */
8
+ codeBackground: string;
9
+ /** Rule beside quotes. */
10
+ quoteColor: string;
11
+ mono: string;
12
+ }
13
+ /** Renders parsed message blocks like the widget's `.msg`: paragraphs, lists, quotes, code and underlined links. */
14
+ export declare function RichText({ blocks, textStyle, codeBackground, quoteColor, mono }: RichTextProps): import("react").JSX.Element;
15
+ export {};
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RichText = RichText;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ const react_1 = require("react");
6
+ const react_native_1 = require("react-native");
7
+ /** Renders parsed message blocks like the widget's `.msg`: paragraphs, lists, quotes, code and underlined links. */
8
+ function RichText({ blocks, textStyle, codeBackground, quoteColor, mono }) {
9
+ const color = textStyle.color;
10
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.blocks, children: blocks.map((block, index) => {
11
+ switch (block.kind) {
12
+ case 'paragraph':
13
+ return ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: textStyle, children: (0, jsx_runtime_1.jsx)(Inlines, { inlines: block.inlines, codeStyle: { fontFamily: mono, backgroundColor: codeBackground } }) }, index));
14
+ case 'heading':
15
+ return ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [textStyle, styles.bold], children: (0, jsx_runtime_1.jsx)(Inlines, { inlines: block.inlines, codeStyle: { fontFamily: mono, backgroundColor: codeBackground } }) }, index));
16
+ case 'quote':
17
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.quote, { borderLeftColor: quoteColor }], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [textStyle, styles.quoteText], children: (0, jsx_runtime_1.jsx)(Inlines, { inlines: block.inlines, codeStyle: { fontFamily: mono, backgroundColor: codeBackground } }) }) }, index));
18
+ case 'code':
19
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.pre, { backgroundColor: codeBackground }], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [textStyle, styles.preText, { fontFamily: mono }], children: block.text }) }, index));
20
+ case 'list':
21
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.list, children: block.items.map((item, itemIndex) => ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.item, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [textStyle, styles.marker, { color }], children: block.ordered ? `${itemIndex + 1}.` : '•' }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [textStyle, styles.itemText], children: (0, jsx_runtime_1.jsx)(Inlines, { inlines: item, codeStyle: { fontFamily: mono, backgroundColor: codeBackground } }) })] }, itemIndex))) }, index));
22
+ }
23
+ }) }));
24
+ }
25
+ function Inlines({ inlines, codeStyle }) {
26
+ return ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: inlines.map((inline, index) => {
27
+ switch (inline.kind) {
28
+ case 'text':
29
+ return (0, jsx_runtime_1.jsx)(react_1.Fragment, { children: inline.text }, index);
30
+ case 'break':
31
+ return (0, jsx_runtime_1.jsx)(react_1.Fragment, { children: '\n' }, index);
32
+ case 'bold':
33
+ return ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.bold, children: (0, jsx_runtime_1.jsx)(Inlines, { inlines: inline.children, codeStyle: codeStyle }) }, index));
34
+ case 'italic':
35
+ return ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.italic, children: (0, jsx_runtime_1.jsx)(Inlines, { inlines: inline.children, codeStyle: codeStyle }) }, index));
36
+ case 'code':
37
+ return ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.code, codeStyle], children: inline.text }, index));
38
+ case 'link':
39
+ return ((0, jsx_runtime_1.jsx)(react_native_1.Text, { accessibilityRole: "link", onPress: () => {
40
+ react_native_1.Linking.openURL(inline.href).catch(() => undefined);
41
+ }, style: styles.link, children: (0, jsx_runtime_1.jsx)(Inlines, { inlines: inline.children, codeStyle: codeStyle }) }, index));
42
+ }
43
+ }) }));
44
+ }
45
+ const styles = react_native_1.StyleSheet.create({
46
+ blocks: { gap: 6 },
47
+ bold: { fontWeight: '600' },
48
+ italic: { fontStyle: 'italic' },
49
+ link: { textDecorationLine: 'underline', fontWeight: '500' },
50
+ code: { fontSize: 12 },
51
+ pre: { borderRadius: 6, paddingHorizontal: 8, paddingVertical: 6 },
52
+ preText: { fontSize: 12, lineHeight: 17 },
53
+ quote: { borderLeftWidth: 2, paddingLeft: 8 },
54
+ quoteText: { opacity: 0.8 },
55
+ list: { gap: 2 },
56
+ item: { flexDirection: 'row' },
57
+ marker: { minWidth: 16, paddingRight: 4 },
58
+ itemText: { flex: 1 },
59
+ });