opendesk-react-native 0.1.2 → 0.1.6

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.
@@ -7,6 +7,7 @@ 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");
10
11
  const strings_1 = require("./strings");
11
12
  const theme_1 = require("./theme");
12
13
  const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
@@ -26,8 +27,10 @@ function OpenDeskChat({ initialConversationId, onClose, pickAttachment, accentCo
26
27
  const strings = (0, react_1.useMemo)(() => ({ ...strings_1.defaultStrings, ...stringOverrides }), [stringOverrides]);
27
28
  const styles = (0, react_1.useMemo)(() => createStyles(theme), [theme]);
28
29
  const ui = (0, react_1.useMemo)(() => ({ theme, strings, styles, bot: config?.bot ?? null }), [theme, strings, styles, config?.bot]);
30
+ const rootRef = (0, react_1.useRef)(null);
31
+ const keyboard = (0, keyboard_1.useKeyboardInset)(rootRef, keyboardVerticalOffset);
29
32
  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: () => {
33
+ 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
34
  setConversationId(null);
32
35
  setComposing(false);
33
36
  }, onClose: onClose, onStarted: (id) => {
@@ -67,6 +70,12 @@ function Thread({ ui, conversationId, pickAttachment, onBack, onClose, onStarted
67
70
  const [focused, setFocused] = (0, react_1.useState)(false);
68
71
  const [inputHeight, setInputHeight] = (0, react_1.useState)(30);
69
72
  const [error, setError] = (0, react_1.useState)(null);
73
+ const [viewing, setViewing] = (0, react_1.useState)(null);
74
+ const openImage = (0, react_1.useCallback)((attachment) => {
75
+ react_native_1.Keyboard.dismiss();
76
+ setViewing(attachment);
77
+ }, []);
78
+ const closeImage = (0, react_1.useCallback)(() => setViewing(null), []);
70
79
  const topics = config?.topics ?? [];
71
80
  const topic = topics.find((t) => t.id === topicId) ?? null;
72
81
  const needsTopic = conversationId === null && topics.length > 0 && topic === null;
@@ -160,14 +169,14 @@ function Thread({ ui, conversationId, pickAttachment, onBack, onClose, onStarted
160
169
  }, [client, config?.attachment_extensions, pickAttachment, strings]);
161
170
  const showAttach = Boolean(pickAttachment) && config?.attachments !== false && (config?.attachment_extensions?.length ?? 1) > 0;
162
171
  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) => {
172
+ 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
173
  setDraft(text);
165
174
  if (conversationId) {
166
175
  client.typing(conversationId);
167
176
  }
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 }) })] })] })] }));
177
+ }, 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
178
  }
170
- const ThreadRow = (0, react_1.memo)(function ThreadRow({ ui, row, showHandoff, onDecide, onRequestHuman, }) {
179
+ const ThreadRow = (0, react_1.memo)(function ThreadRow({ ui, row, showHandoff, onDecide, onRequestHuman, onOpenImage, }) {
171
180
  const { theme, strings, styles } = ui;
172
181
  switch (row.kind) {
173
182
  case 'day':
@@ -177,16 +186,16 @@ const ThreadRow = (0, react_1.memo)(function ThreadRow({ ui, row, showHandoff, o
177
186
  case 'handed':
178
187
  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
188
  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 });
189
+ 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
190
  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 }));
191
+ 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
192
  }
184
193
  });
185
194
  /**
186
195
  * One bubble for a stored message or an AI answer still streaming. Both share one element
187
196
  * tree, so when the stored message replaces the stream (same row key) nothing remounts.
188
197
  */
189
- function Bubble({ ui, message, reply, first, last, gap, showHandoff, onDecide, onRequestHuman, }) {
198
+ function Bubble({ ui, message, reply, first, last, gap, showHandoff, onDecide, onRequestHuman, onOpenImage, }) {
190
199
  const { theme, strings, styles, bot: fallbackBot } = ui;
191
200
  const mine = message?.direction === 'inbound';
192
201
  const ai = reply !== undefined || message?.ai === true;
@@ -210,7 +219,7 @@ function Bubble({ ui, message, reply, first, last, gap, showHandoff, onDecide, o
210
219
  mine ? styles.msgMe : ai ? styles.msgAi : styles.msgThem,
211
220
  corners,
212
221
  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] }));
222
+ ], 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, { 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] }));
214
223
  }
215
224
  function ActionCard({ ui, message, onDecide, }) {
216
225
  const { theme, strings, styles } = ui;
@@ -225,6 +234,21 @@ function ActionCard({ ui, message, onDecide, }) {
225
234
  }[action.status];
226
235
  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
236
  }
237
+ /** An attached image shown full size over the thread, so it opens in the chat instead of a browser. */
238
+ function ImageViewer({ ui, attachment, onClose }) {
239
+ const { strings, styles } = ui;
240
+ (0, react_1.useEffect)(() => {
241
+ if (react_native_1.Platform.OS !== 'android') {
242
+ return;
243
+ }
244
+ const subscription = react_native_1.BackHandler.addEventListener('hardwareBackPress', () => {
245
+ onClose();
246
+ return true;
247
+ });
248
+ return () => subscription.remove();
249
+ }, [onClose]);
250
+ 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 }) })] }));
251
+ }
228
252
  function IconButton({ ui, label, onPress, icon, disabled, }) {
229
253
  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
254
  }
@@ -377,13 +401,13 @@ function createStyles(t) {
377
401
  alignItems: 'center',
378
402
  gap: 10,
379
403
  paddingHorizontal: 12,
380
- paddingTop: 12,
381
- paddingBottom: 11,
404
+ paddingTop: 18,
405
+ paddingBottom: 14,
382
406
  borderBottomWidth: 1,
383
407
  borderBottomColor: t.line,
384
408
  backgroundColor: t.bg,
385
409
  },
386
- headHome: { flexDirection: 'column', alignItems: 'stretch', gap: 0, padding: 16, paddingBottom: 20, backgroundColor: t.headBg },
410
+ headHome: { flexDirection: 'column', alignItems: 'stretch', gap: 0, padding: 16, paddingTop: 22, paddingBottom: 20, backgroundColor: t.headBg },
387
411
  headRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
388
412
  headMain: { flex: 1, minWidth: 0 },
389
413
  ws: { flexDirection: 'row', alignItems: 'center', gap: 9, flexShrink: 1 },
@@ -489,6 +513,12 @@ function createStyles(t) {
489
513
  toolText: { fontSize: 12, lineHeight: 18, color: t.fg3 },
490
514
  fileGap: { marginTop: 4 },
491
515
  thumb: { width: 220, height: 160, borderRadius: r.card, borderWidth: 1, borderColor: t.line, backgroundColor: t.bg2 },
516
+ viewer: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, zIndex: 10, elevation: 10, backgroundColor: '#000000' },
517
+ viewerHead: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 12, paddingTop: 18, paddingBottom: 14 },
518
+ viewerName: { flex: 1, fontSize: 14, fontWeight: '600', color: '#ffffff' },
519
+ viewerPressed: { backgroundColor: 'rgba(255,255,255,0.16)' },
520
+ viewerBody: { flexGrow: 1 },
521
+ viewerImage: { flex: 1, width: '100%' },
492
522
  chip: {
493
523
  flexDirection: 'row',
494
524
  alignItems: 'center',
@@ -0,0 +1,15 @@
1
+ import { type RefObject } from 'react';
2
+ import { type View } from 'react-native';
3
+ /**
4
+ * How far the keyboard covers the view in `ref`, for use as its bottom padding.
5
+ *
6
+ * KeyboardAvoidingView compares the keyboard with a layout relative to its parent, so it misses
7
+ * anything above that parent (a header, a sheet), and on Android it relies on the window
8
+ * shrinking, which edge-to-edge apps no longer do. This measures the view in window coordinates
9
+ * instead and works the same on both platforms. Call `onLayout` from the view's `onLayout` so a
10
+ * window that does shrink is measured again and the padding drops back.
11
+ */
12
+ export declare function useKeyboardInset(ref: RefObject<View | null>, extraOffset?: number): {
13
+ inset: number;
14
+ onLayout: () => void;
15
+ };
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useKeyboardInset = useKeyboardInset;
4
+ const react_1 = require("react");
5
+ const react_native_1 = require("react-native");
6
+ /**
7
+ * How far the keyboard covers the view in `ref`, for use as its bottom padding.
8
+ *
9
+ * KeyboardAvoidingView compares the keyboard with a layout relative to its parent, so it misses
10
+ * anything above that parent (a header, a sheet), and on Android it relies on the window
11
+ * shrinking, which edge-to-edge apps no longer do. This measures the view in window coordinates
12
+ * instead and works the same on both platforms. Call `onLayout` from the view's `onLayout` so a
13
+ * window that does shrink is measured again and the padding drops back.
14
+ */
15
+ function useKeyboardInset(ref, extraOffset = 0) {
16
+ const [inset, setInset] = (0, react_1.useState)(0);
17
+ const keyboard = (0, react_1.useRef)(null);
18
+ const measure = (0, react_1.useCallback)(() => {
19
+ const event = keyboard.current;
20
+ const view = ref.current;
21
+ if (!event || !view) {
22
+ setInset(0);
23
+ return;
24
+ }
25
+ view.measureInWindow((_x, y, _width, height) => {
26
+ if (keyboard.current !== event) {
27
+ return;
28
+ }
29
+ const next = Math.max(0, Math.round(y + height - event.endCoordinates.screenY + extraOffset));
30
+ setInset((current) => {
31
+ if (current !== next) {
32
+ animateWithKeyboard(event);
33
+ }
34
+ return next;
35
+ });
36
+ });
37
+ }, [ref, extraOffset]);
38
+ (0, react_1.useEffect)(() => {
39
+ const show = (event) => {
40
+ keyboard.current = event;
41
+ measure();
42
+ };
43
+ const hide = (event) => {
44
+ keyboard.current = null;
45
+ animateWithKeyboard(event);
46
+ setInset(0);
47
+ };
48
+ const subscriptions = react_native_1.Platform.OS === 'ios'
49
+ ? [react_native_1.Keyboard.addListener('keyboardWillChangeFrame', show), react_native_1.Keyboard.addListener('keyboardWillHide', hide)]
50
+ : [react_native_1.Keyboard.addListener('keyboardDidShow', show), react_native_1.Keyboard.addListener('keyboardDidHide', hide)];
51
+ return () => subscriptions.forEach((subscription) => subscription.remove());
52
+ }, [measure]);
53
+ return { inset, onLayout: measure };
54
+ }
55
+ /** iOS reports the keyboard's own curve, so the padding can move in step with it. */
56
+ function animateWithKeyboard(event) {
57
+ if (react_native_1.Platform.OS !== 'ios' || !event?.duration) {
58
+ return;
59
+ }
60
+ const type = react_native_1.LayoutAnimation.Types[event.easing] ?? react_native_1.LayoutAnimation.Types.keyboard;
61
+ react_native_1.LayoutAnimation.configureNext({ duration: event.duration, update: { duration: event.duration, type } });
62
+ }
@@ -20,6 +20,8 @@ export declare const defaultStrings: {
20
20
  sending: string;
21
21
  uploading: string;
22
22
  removeFile: string;
23
+ viewImage: string;
24
+ closeImage: string;
23
25
  fileTypeRefused: string;
24
26
  fileTooLarge: string;
25
27
  uploadFailed: string;
package/lib/ui/strings.js CHANGED
@@ -24,6 +24,8 @@ exports.defaultStrings = {
24
24
  sending: 'Sending…',
25
25
  uploading: 'Uploading…',
26
26
  removeFile: 'Remove {name}',
27
+ viewImage: 'View {name}',
28
+ closeImage: 'Close image',
27
29
  fileTypeRefused: "{name} can't be sent here. Try another file type.",
28
30
  fileTooLarge: '{name} is larger than 25 MB.',
29
31
  uploadFailed: "Couldn't upload {name}. Try again.",
package/package.json CHANGED
@@ -1,45 +1,46 @@
1
- {
2
- "name": "opendesk-react-native",
3
- "version": "0.1.2",
4
- "description": "React Native SDK for openDesk live chat: headless client, realtime, hooks and a drop-in chat screen.",
5
- "main": "lib/index.js",
6
- "types": "lib/index.d.ts",
7
- "files": [
8
- "lib",
9
- "src",
10
- "!src/**/__tests__"
11
- ],
12
- "scripts": {
13
- "build": "tsc -p tsconfig.build.json",
14
- "typecheck": "tsc --noEmit",
15
- "test": "vitest run",
16
- "prepare": "npm run build",
17
- "example": "vite --config example/vite.config.ts"
18
- },
19
- "keywords": [
20
- "opendesk",
21
- "react-native",
22
- "live-chat",
23
- "support"
24
- ],
25
- "license": "MIT",
26
- "dependencies": {
27
- "phoenix": "^1.7.21"
28
- },
29
- "peerDependencies": {
30
- "react": ">=18",
31
- "react-native": ">=0.72"
32
- },
33
- "devDependencies": {
34
- "@types/phoenix": "^1.6.6",
35
- "@types/react": "^19.1.0",
36
- "@vitejs/plugin-react": "^5.2.0",
37
- "react": "^19.1.0",
38
- "react-dom": "^19.3.1",
39
- "react-native": "^0.81.0",
40
- "react-native-web": "^0.21.4",
41
- "typescript": "^5.9.2",
42
- "vite": "^7.3.7",
43
- "vitest": "^3.2.4"
44
- }
45
- }
1
+ {
2
+ "name": "opendesk-react-native",
3
+ "version": "0.1.6",
4
+ "description": "React Native SDK for openDesk live chat: headless client, realtime, hooks and a drop-in chat screen.",
5
+ "main": "lib/index.js",
6
+ "types": "lib/index.d.ts",
7
+ "files": [
8
+ "lib",
9
+ "src",
10
+ "assets",
11
+ "!src/**/__tests__"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc -p tsconfig.build.json",
15
+ "typecheck": "tsc --noEmit",
16
+ "test": "vitest run",
17
+ "prepare": "npm run build",
18
+ "example": "vite --config example/vite.config.ts"
19
+ },
20
+ "keywords": [
21
+ "opendesk",
22
+ "react-native",
23
+ "live-chat",
24
+ "support"
25
+ ],
26
+ "license": "MIT",
27
+ "dependencies": {
28
+ "phoenix": "^1.8.0"
29
+ },
30
+ "peerDependencies": {
31
+ "react": ">=18",
32
+ "react-native": ">=0.72"
33
+ },
34
+ "devDependencies": {
35
+ "@types/phoenix": "^1.6.6",
36
+ "@types/react": "^19.1.0",
37
+ "@vitejs/plugin-react": "^5.2.0",
38
+ "react": "^19.1.0",
39
+ "react-dom": "^19.3.1",
40
+ "react-native": "^0.81.0",
41
+ "react-native-web": "^0.21.4",
42
+ "typescript": "^5.9.2",
43
+ "vite": "^7.3.7",
44
+ "vitest": "^3.2.4"
45
+ }
46
+ }