@repzo/widget-react-native 1.0.0 → 1.0.1

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.
Files changed (126) hide show
  1. package/README.md +167 -74
  2. package/lib/commonjs/api/client.js.map +1 -1
  3. package/lib/commonjs/attachments/pickers.js +493 -0
  4. package/lib/commonjs/attachments/pickers.js.map +1 -0
  5. package/lib/commonjs/i18n.js +19 -0
  6. package/lib/commonjs/i18n.js.map +1 -1
  7. package/lib/commonjs/index.js.map +1 -1
  8. package/lib/commonjs/locales.js +16 -0
  9. package/lib/commonjs/locales.js.map +1 -1
  10. package/lib/commonjs/state/widget-store.js +147 -13
  11. package/lib/commonjs/state/widget-store.js.map +1 -1
  12. package/lib/commonjs/types.js +14 -0
  13. package/lib/commonjs/types.js.map +1 -1
  14. package/lib/commonjs/ui/WidgetProvider.js +53 -8
  15. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  16. package/lib/commonjs/ui/components/AttachmentChips.js +135 -0
  17. package/lib/commonjs/ui/components/AttachmentChips.js.map +1 -0
  18. package/lib/commonjs/ui/components/AttachmentMenu.js +125 -0
  19. package/lib/commonjs/ui/components/AttachmentMenu.js.map +1 -0
  20. package/lib/commonjs/ui/components/ImageViewer.js +181 -0
  21. package/lib/commonjs/ui/components/ImageViewer.js.map +1 -0
  22. package/lib/commonjs/ui/components/InAppNotification.js +210 -0
  23. package/lib/commonjs/ui/components/InAppNotification.js.map +1 -0
  24. package/lib/commonjs/ui/screens/chat/ChatView.js +198 -18
  25. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  26. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +19 -0
  27. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  28. package/lib/module/api/client.js.map +1 -1
  29. package/lib/module/attachments/pickers.js +479 -0
  30. package/lib/module/attachments/pickers.js.map +1 -0
  31. package/lib/module/i18n.js +19 -0
  32. package/lib/module/i18n.js.map +1 -1
  33. package/lib/module/index.js.map +1 -1
  34. package/lib/module/locales.js +16 -0
  35. package/lib/module/locales.js.map +1 -1
  36. package/lib/module/state/widget-store.js +147 -13
  37. package/lib/module/state/widget-store.js.map +1 -1
  38. package/lib/module/types.js +14 -0
  39. package/lib/module/types.js.map +1 -1
  40. package/lib/module/ui/WidgetProvider.js +53 -8
  41. package/lib/module/ui/WidgetProvider.js.map +1 -1
  42. package/lib/module/ui/components/AttachmentChips.js +131 -0
  43. package/lib/module/ui/components/AttachmentChips.js.map +1 -0
  44. package/lib/module/ui/components/AttachmentMenu.js +120 -0
  45. package/lib/module/ui/components/AttachmentMenu.js.map +1 -0
  46. package/lib/module/ui/components/ImageViewer.js +177 -0
  47. package/lib/module/ui/components/ImageViewer.js.map +1 -0
  48. package/lib/module/ui/components/InAppNotification.js +208 -0
  49. package/lib/module/ui/components/InAppNotification.js.map +1 -0
  50. package/lib/module/ui/screens/chat/ChatView.js +201 -21
  51. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  52. package/lib/module/ui/screens/chat/ChatView.styles.js +19 -0
  53. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  54. package/lib/typescript/commonjs/api/client.d.ts +8 -2
  55. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/attachments/pickers.d.ts +81 -0
  57. package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -0
  58. package/lib/typescript/commonjs/i18n.d.ts +16 -0
  59. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/index.d.ts +1 -1
  61. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/state/widget-store.d.ts +23 -2
  64. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/types.d.ts +74 -0
  66. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts +16 -0
  69. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts.map +1 -0
  70. package/lib/typescript/commonjs/ui/components/AttachmentMenu.d.ts +28 -0
  71. package/lib/typescript/commonjs/ui/components/AttachmentMenu.d.ts.map +1 -0
  72. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +23 -0
  73. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -0
  74. package/lib/typescript/commonjs/ui/components/InAppNotification.d.ts +28 -0
  75. package/lib/typescript/commonjs/ui/components/InAppNotification.d.ts.map +1 -0
  76. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +3 -2
  77. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  78. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +18 -0
  79. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +28 -2
  81. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  82. package/lib/typescript/module/api/client.d.ts +8 -2
  83. package/lib/typescript/module/api/client.d.ts.map +1 -1
  84. package/lib/typescript/module/attachments/pickers.d.ts +81 -0
  85. package/lib/typescript/module/attachments/pickers.d.ts.map +1 -0
  86. package/lib/typescript/module/i18n.d.ts +16 -0
  87. package/lib/typescript/module/i18n.d.ts.map +1 -1
  88. package/lib/typescript/module/index.d.ts +1 -1
  89. package/lib/typescript/module/index.d.ts.map +1 -1
  90. package/lib/typescript/module/locales.d.ts.map +1 -1
  91. package/lib/typescript/module/state/widget-store.d.ts +23 -2
  92. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  93. package/lib/typescript/module/types.d.ts +74 -0
  94. package/lib/typescript/module/types.d.ts.map +1 -1
  95. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  96. package/lib/typescript/module/ui/components/AttachmentChips.d.ts +16 -0
  97. package/lib/typescript/module/ui/components/AttachmentChips.d.ts.map +1 -0
  98. package/lib/typescript/module/ui/components/AttachmentMenu.d.ts +28 -0
  99. package/lib/typescript/module/ui/components/AttachmentMenu.d.ts.map +1 -0
  100. package/lib/typescript/module/ui/components/ImageViewer.d.ts +23 -0
  101. package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -0
  102. package/lib/typescript/module/ui/components/InAppNotification.d.ts +28 -0
  103. package/lib/typescript/module/ui/components/InAppNotification.d.ts.map +1 -0
  104. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +3 -2
  105. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  106. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +18 -0
  107. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  108. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +28 -2
  109. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  110. package/metro.js +17 -32
  111. package/package.json +18 -1
  112. package/src/api/client.ts +2 -2
  113. package/src/attachments/pickers.ts +515 -0
  114. package/src/i18n.ts +20 -0
  115. package/src/index.tsx +4 -0
  116. package/src/locales.ts +16 -0
  117. package/src/state/widget-store.ts +152 -16
  118. package/src/types.ts +79 -0
  119. package/src/ui/WidgetProvider.tsx +65 -6
  120. package/src/ui/components/AttachmentChips.tsx +135 -0
  121. package/src/ui/components/AttachmentMenu.tsx +132 -0
  122. package/src/ui/components/ImageViewer.tsx +178 -0
  123. package/src/ui/components/InAppNotification.tsx +225 -0
  124. package/src/ui/screens/chat/ChatView.styles.ts +19 -0
  125. package/src/ui/screens/chat/ChatView.tsx +235 -16
  126. package/src/ui/screens/chat/ChatView.types.ts +38 -2
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * Mobile differences: older messages load through an explicit "Load earlier
6
6
  * messages" control (a scroll-anchored infinite list fights the keyboard), and
7
- * the composer hides the attach button because v1 has no picker.
7
+ * the attach button only shows when the host app has a picker to open (see
8
+ * `attachments/pickers.ts`).
8
9
  */
9
10
 
10
11
  import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
@@ -12,6 +13,7 @@ import {
12
13
  Animated,
13
14
  Easing,
14
15
  Image,
16
+ Platform,
15
17
  Pressable,
16
18
  ScrollView,
17
19
  Text,
@@ -25,19 +27,34 @@ import {
25
27
  getAgentUiBlocks,
26
28
  getProcedureForm,
27
29
  } from '../../../agent-ui-blocks';
28
- import type { AgentUiAction, AgentProcedureForm, Message, WidgetAttachment } from '../../../types';
30
+ import {
31
+ MAX_ATTACHMENTS,
32
+ isImageAttachment,
33
+ type AttachmentProblem,
34
+ type DraftAttachment,
35
+ } from '../../../attachments/pickers';
36
+ import type {
37
+ AgentUiAction,
38
+ AgentProcedureForm,
39
+ AttachmentSource,
40
+ Message,
41
+ WidgetAttachment,
42
+ } from '../../../types';
29
43
  import { AgentUiBlocks } from '../../components/AgentUiBlocks';
44
+ import { AttachmentChips } from '../../components/AttachmentChips';
45
+ import { AttachmentMenu, showNativeAttachmentMenu } from '../../components/AttachmentMenu';
30
46
  import { Avatar } from '../../components/Avatar';
47
+ import { ImageViewer } from '../../components/ImageViewer';
31
48
  import { WidgetErrorBoundary } from '../../components/ErrorBoundary';
32
49
  import { GradientBackground } from '../../components/Gradient';
33
- import { ArrowUpIcon, ChatOutlineIcon, ChevronIcon } from '../../components/icons';
50
+ import { ArrowUpIcon, ChatOutlineIcon, ChevronIcon, PaperclipIcon } from '../../components/icons';
34
51
  import { Markdown } from '../../components/Markdown';
35
52
  import { useReduceMotion } from '../../hooks/use-reduce-motion';
36
53
  import { globalStyles } from '../../styles/global';
37
54
  import { contrastingTextColor, mixHex, palette, withAlpha } from '../../styles/theme';
38
55
  import { formatClockTime, formatDayLabel, isSameDay } from '../../utils/conversations';
39
56
  import { stripHtml } from '../../utils/html';
40
- import { openExternalUrl } from '../../utils/safe-url';
57
+ import { isHttpUrl, openExternalUrl } from '../../utils/safe-url';
41
58
  import { backDirection, isRTL } from '../../utils/rtl';
42
59
  import { styles } from './ChatView.styles';
43
60
  import type { ChatLabels, ChatViewProps } from './ChatView.types';
@@ -55,6 +72,8 @@ export function ChatView({
55
72
  prefillText,
56
73
  offlineMessage,
57
74
  labels,
75
+ attachmentSources = [],
76
+ onPickAttachments,
58
77
  onSendMessage,
59
78
  onIdentifyVisitor,
60
79
  onLoadMore,
@@ -64,20 +83,41 @@ export function ChatView({
64
83
  }: ChatViewProps) {
65
84
  const [input, setInput] = useState(prefillText || '');
66
85
  const [composerFocused, setComposerFocused] = useState(false);
86
+ const [drafts, setDrafts] = useState<DraftAttachment[]>([]);
87
+ const [menuOpen, setMenuOpen] = useState(false);
88
+ const [viewerImage, setViewerImage] = useState<{ url: string; name: string } | null>(null);
89
+ const [picking, setPicking] = useState(false);
90
+ // Only attachment sends hold the composer; text sends stay fire-and-forget.
91
+ const [sending, setSending] = useState(false);
92
+ const [notice, setNotice] = useState<string | null>(null);
67
93
  const reduceMotion = useReduceMotion();
68
94
  const scrollRef = useRef<ScrollView>(null);
69
95
  const typingStopTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
96
+ const noticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
97
+ const pickerTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
98
+ const mounted = useRef(false);
70
99
 
71
100
  useEffect(() => {
72
101
  if (prefillText) setInput(prefillText);
73
102
  }, [prefillText]);
74
103
 
75
- useEffect(
76
- () => () => {
104
+ // The provider keys this screen by the store's thread generation. Pending
105
+ // picks and sends belong to this mount, even before a draft gets a server ID.
106
+ useEffect(() => {
107
+ mounted.current = true;
108
+ return () => {
109
+ mounted.current = false;
77
110
  if (typingStopTimer.current) clearTimeout(typingStopTimer.current);
78
- },
79
- []
80
- );
111
+ if (noticeTimer.current) clearTimeout(noticeTimer.current);
112
+ if (pickerTimer.current) clearTimeout(pickerTimer.current);
113
+ };
114
+ }, []);
115
+
116
+ const showNotice = useCallback((message: string | null) => {
117
+ if (noticeTimer.current) clearTimeout(noticeTimer.current);
118
+ setNotice(message);
119
+ if (message) noticeTimer.current = setTimeout(() => setNotice(null), 5000);
120
+ }, []);
81
121
 
82
122
  const scrollToBottom = useCallback((animated = true) => {
83
123
  scrollRef.current?.scrollToEnd({ animated });
@@ -122,16 +162,120 @@ export function ChatView({
122
162
  [scrollToBottom]
123
163
  );
124
164
 
125
- const canSend = input.trim().length > 0;
165
+ const showAttach = attachmentSources.length > 0 && !!onPickAttachments;
166
+ const canSend = !sending && (input.trim().length > 0 || drafts.length > 0);
167
+
168
+ const problemLabel = (problem: AttachmentProblem | null): string | null => {
169
+ switch (problem) {
170
+ case 'tooMany':
171
+ return labels.attach.tooMany;
172
+ case 'tooLarge':
173
+ return labels.attach.tooLarge;
174
+ case 'tooLargeTogether':
175
+ return labels.attach.tooLargeTogether;
176
+ case 'cameraDenied':
177
+ return labels.attach.cameraDenied;
178
+ case 'failed':
179
+ return labels.attach.failed;
180
+ default:
181
+ return null;
182
+ }
183
+ };
184
+
185
+ const handlePick = async (source: AttachmentSource) => {
186
+ if (!mounted.current || !onPickAttachments || picking) return;
187
+ setPicking(true);
188
+ try {
189
+ const result = await onPickAttachments(source, drafts);
190
+ if (!mounted.current) return;
191
+ if (result.added.length > 0) {
192
+ setDrafts((current) => [...current, ...result.added].slice(0, MAX_ATTACHMENTS));
193
+ }
194
+ showNotice(problemLabel(result.problem));
195
+ } finally {
196
+ if (mounted.current) setPicking(false);
197
+ }
198
+ };
199
+
200
+ const menuOptions = attachmentSources.map((source) => ({
201
+ key: source,
202
+ label:
203
+ source === 'photo'
204
+ ? labels.attach.photoLibrary
205
+ : source === 'camera'
206
+ ? labels.attach.takePhoto
207
+ : labels.attach.file,
208
+ }));
209
+
210
+ const handleAttachPress = () => {
211
+ if (sending || picking) return;
212
+ if (drafts.length >= MAX_ATTACHMENTS) {
213
+ showNotice(labels.attach.tooMany);
214
+ return;
215
+ }
216
+ const only = attachmentSources.length === 1 ? attachmentSources[0] : undefined;
217
+ if (only) {
218
+ void handlePick(only);
219
+ return;
220
+ }
221
+ const native = showNativeAttachmentMenu(menuOptions, labels.attach.cancel, (source) => {
222
+ if (!mounted.current) return;
223
+ // Presenting a picker while the action sheet is still animating away
224
+ // fails silently on iOS; let it finish first.
225
+ pickerTimer.current = setTimeout(() => void handlePick(source), 350);
226
+ });
227
+ if (!native) setMenuOpen(true);
228
+ };
229
+
230
+ const handleMenuSelect = (source: AttachmentSource) => {
231
+ setMenuOpen(false);
232
+ void handlePick(source);
233
+ };
126
234
 
127
235
  const handleSend = () => {
128
236
  const trimmed = input.trim();
129
- if (!trimmed) return;
237
+ if (!canSend) return;
130
238
  setInput('');
131
239
  if (typingStopTimer.current) clearTimeout(typingStopTimer.current);
132
240
  onTyping(false);
133
- onSendMessage(trimmed);
134
241
  scrollToBottom();
242
+
243
+ if (drafts.length === 0) {
244
+ // The failed bubble already surfaces a failure to the visitor.
245
+ onSendMessage(trimmed).catch(() => {});
246
+ return;
247
+ }
248
+
249
+ const sent = drafts;
250
+ setSending(true);
251
+ showNotice(null);
252
+ onSendMessage(
253
+ trimmed,
254
+ sent.map((draft) => ({
255
+ name: draft.name,
256
+ url: draft.dataUrl,
257
+ contentType: draft.contentType,
258
+ size: draft.size,
259
+ previewUrl: draft.previewUri,
260
+ }))
261
+ )
262
+ .then(() => {
263
+ if (!mounted.current) return;
264
+ const sentIds = new Set(sent.map((draft) => draft.id));
265
+ setDrafts((current) => current.filter((draft) => !sentIds.has(draft.id)));
266
+ })
267
+ .catch((error: unknown) => {
268
+ // The visitor has moved to another thread: nothing to restore there.
269
+ if (!mounted.current) return;
270
+ // Chips stay for a retry; put the text back too unless they've
271
+ // already started typing something else.
272
+ setInput((current) => current || trimmed);
273
+ const status = (error as { status?: unknown } | null)?.status;
274
+ showNotice(status === 413 ? labels.attach.tooLargeTogether : labels.failedToSend);
275
+ })
276
+ .finally(() => {
277
+ if (mounted.current) setSending(false);
278
+ });
135
279
  };
136
280
 
137
281
  const handleChangeText = (text: string) => {
@@ -156,7 +300,7 @@ export function ChatView({
156
300
  phone: stringPayload(action.payload.phone),
157
301
  });
158
302
  }
159
- onSendMessage(formatAgentUiActionMessage(action), undefined, action);
303
+ onSendMessage(formatAgentUiActionMessage(action), undefined, action).catch(() => {});
160
304
  scrollToBottom();
161
305
  },
162
306
  [onIdentifyVisitor, onSendMessage, scrollToBottom]
@@ -292,6 +436,7 @@ export function ChatView({
292
436
  message={message}
293
437
  color={color}
294
438
  labels={labels}
439
+ onOpenImage={setViewerImage}
295
440
  senderLabel={
296
441
  isAgentMessage && (startsRun || startsDay)
297
442
  ? message.senderName || agentName || labels.team
@@ -300,7 +445,7 @@ export function ChatView({
300
445
  timeLabel={formatClockTime(message.createdAt, locale)}
301
446
  onSubmitProcedureForm={
302
447
  shouldShowProcedureForm(messages, message)
303
- ? (body) => onSendMessage(body)
448
+ ? (body) => void onSendMessage(body).catch(() => {})
304
449
  : undefined
305
450
  }
306
451
  onAgentUiAction={
@@ -331,12 +476,48 @@ export function ChatView({
331
476
  ) : null}
332
477
 
333
478
  <View style={styles.composerWrapper}>
479
+ {notice ? (
480
+ <Text
481
+ style={styles.composerNotice}
482
+ accessibilityRole="alert"
483
+ accessibilityLiveRegion="polite">
484
+ {notice}
485
+ </Text>
486
+ ) : null}
487
+ {drafts.length > 0 ? (
488
+ <AttachmentChips
489
+ attachments={drafts}
490
+ disabled={sending}
491
+ removeLabel={labels.attach.remove}
492
+ onRemove={(id) => {
493
+ setDrafts((current) => current.filter((draft) => draft.id !== id));
494
+ showNotice(null);
495
+ }}
496
+ />
497
+ ) : null}
334
498
  <View
335
499
  style={[
336
500
  styles.composer,
501
+ showAttach && styles.composerWithAttach,
337
502
  composerFocused && styles.composerFocused,
338
503
  composerFocused && { borderColor: withAlpha(color, 0.55) },
339
504
  ]}>
505
+ {showAttach ? (
506
+ <Pressable
507
+ onPress={handleAttachPress}
508
+ disabled={sending || picking}
509
+ accessibilityRole="button"
510
+ accessibilityLabel={labels.attach.button}
511
+ accessibilityState={{ disabled: sending || picking }}
512
+ // 36pt circle + 4pt on every side = a 44pt target.
513
+ hitSlop={4}
514
+ style={({ pressed }) => [
515
+ styles.attachButton,
516
+ (pressed || sending || picking) && globalStyles.pressed,
517
+ ]}>
518
+ <PaperclipIcon size={20} color={palette.textMuted} />
519
+ </Pressable>
520
+ ) : null}
340
521
  <TextInput
341
522
  value={input}
342
523
  onChangeText={handleChangeText}
@@ -366,6 +547,25 @@ export function ChatView({
366
547
  </Pressable>
367
548
  </View>
368
549
  </View>
550
+
551
+ {showAttach && Platform.OS !== 'ios' ? (
552
+ <AttachmentMenu
553
+ visible={menuOpen}
554
+ options={menuOptions}
555
+ cancelLabel={labels.attach.cancel}
556
+ onSelect={handleMenuSelect}
557
+ onClose={() => setMenuOpen(false)}
558
+ />
559
+ ) : null}
560
+ <ImageViewer
561
+ image={viewerImage}
562
+ labels={labels.viewer}
563
+ onClose={() => setViewerImage(null)}
564
+ onOpenExternally={(url) => {
565
+ setViewerImage(null);
566
+ void openExternalUrl(url);
567
+ }}
568
+ />
369
569
  </View>
370
570
  );
371
571
  }
@@ -378,6 +578,7 @@ function MessageBubble({
378
578
  timeLabel,
379
579
  onSubmitProcedureForm,
380
580
  onAgentUiAction,
581
+ onOpenImage,
381
582
  }: {
382
583
  message: Message;
383
584
  color: string;
@@ -386,6 +587,7 @@ function MessageBubble({
386
587
  timeLabel: string;
387
588
  onSubmitProcedureForm?: (body: string) => void;
388
589
  onAgentUiAction?: (action: AgentUiAction) => void | Promise<void>;
590
+ onOpenImage: (image: { url: string; name: string }) => void;
389
591
  }) {
390
592
  if (message.messageType === 'system') {
391
593
  const text = message.bodyPlain || stripHtml(message.body || '') || labels.conversationUpdated;
@@ -429,6 +631,7 @@ function MessageBubble({
429
631
  key={attachment.id}
430
632
  attachment={attachment}
431
633
  isVisitor={isVisitor}
634
+ onOpenImage={onOpenImage}
432
635
  />
433
636
  ))}
434
637
  </View>
@@ -479,18 +682,30 @@ function MessageBubble({
479
682
  function AttachmentPreview({
480
683
  attachment,
481
684
  isVisitor,
685
+ onOpenImage,
482
686
  }: {
483
687
  attachment: WidgetAttachment;
484
688
  isVisitor: boolean;
689
+ onOpenImage: (image: { url: string; name: string }) => void;
485
690
  }) {
486
691
  // `url` is empty when the server sent a non-http(s) URL (see resolveUrl).
487
- const isImage = attachment.contentType?.startsWith('image/') && !!attachment.url;
488
- const open = () => void openExternalUrl(attachment.url);
692
+ // An unsent attachment shows its local file (or data URL): it renders, but
693
+ // there is nothing to open until the server copy arrives.
694
+ const [imageFailed, setImageFailed] = useState(false);
695
+ const isImage = isImageAttachment(attachment.contentType) && !!attachment.url && !imageFailed;
696
+ const openable = isHttpUrl(attachment.url);
697
+ // Images open in the in-app viewer; other files (PDF, sheets) need the OS.
698
+ const open = !openable
699
+ ? undefined
700
+ : isImage
701
+ ? () => onOpenImage({ url: attachment.url, name: attachment.name })
702
+ : () => void openExternalUrl(attachment.url);
489
703
 
490
704
  if (isImage) {
491
705
  return (
492
706
  <Pressable
493
707
  onPress={open}
708
+ disabled={!openable}
494
709
  accessibilityRole="imagebutton"
495
710
  accessibilityLabel={attachment.name}>
496
711
  <Image
@@ -498,6 +713,9 @@ function AttachmentPreview({
498
713
  style={styles.attachmentImage}
499
714
  resizeMode="cover"
500
715
  accessibilityIgnoresInvertColors
716
+ // An image that can't load (expired link, deleted file) falls back to
717
+ // the file row below instead of an empty box.
718
+ onError={() => setImageFailed(true)}
501
719
  />
502
720
  </Pressable>
503
721
  );
@@ -506,6 +724,7 @@ function AttachmentPreview({
506
724
  return (
507
725
  <Pressable
508
726
  onPress={open}
727
+ disabled={!openable}
509
728
  accessibilityRole="button"
510
729
  style={[
511
730
  styles.attachmentFile,
@@ -1,7 +1,14 @@
1
1
  /** Props and label bundle for the chat thread. */
2
2
 
3
- import type { AgentUiAction, Message, WidgetAttachment } from '../../../types';
3
+ import type { DraftAttachment, PickResult } from '../../../attachments/pickers';
4
+ import type {
5
+ AgentUiAction,
6
+ AttachmentSource,
7
+ Message,
8
+ OutgoingAttachment,
9
+ } from '../../../types';
4
10
  import type { AgentUiBlockLabels } from '../../components/AgentUiBlocks';
11
+ import type { ImageViewerLabels } from '../../components/ImageViewer';
5
12
 
6
13
  export interface ChatLabels {
7
14
  back: string;
@@ -21,6 +28,23 @@ export interface ChatLabels {
21
28
  procedureSend: string;
22
29
  procedureSent: string;
23
30
  blocks: AgentUiBlockLabels;
31
+ attach: AttachLabels;
32
+ viewer: ImageViewerLabels;
33
+ }
34
+
35
+ /** Composer attachment copy. Limits are already filled in; `remove` keeps `{name}`. */
36
+ export interface AttachLabels {
37
+ button: string;
38
+ photoLibrary: string;
39
+ takePhoto: string;
40
+ file: string;
41
+ cancel: string;
42
+ remove: string;
43
+ tooLarge: string;
44
+ tooMany: string;
45
+ tooLargeTogether: string;
46
+ failed: string;
47
+ cameraDenied: string;
24
48
  }
25
49
 
26
50
  export interface ChatViewProps {
@@ -37,7 +61,19 @@ export interface ChatViewProps {
37
61
  offlineMessage?: string;
38
62
  locale: string;
39
63
  labels: ChatLabels;
40
- onSendMessage: (body: string, attachments?: WidgetAttachment[], action?: AgentUiAction) => void;
64
+ /**
65
+ * Menu options for the attach button, in order. Empty (or absent) hides the
66
+ * button — attachments are off, or no picker is installed.
67
+ */
68
+ attachmentSources?: AttachmentSource[];
69
+ /** Opens a picker and returns what fits beside `current`. Never rejects. */
70
+ onPickAttachments?: (source: AttachmentSource, current: DraftAttachment[]) => Promise<PickResult>;
71
+ /** Rejects when the send fails, so the composer can keep its attachments. */
72
+ onSendMessage: (
73
+ body: string,
74
+ attachments?: OutgoingAttachment[],
75
+ action?: AgentUiAction
76
+ ) => Promise<void>;
41
77
  onIdentifyVisitor?: (data: { email?: string; name?: string; phone?: string }) => Promise<void>;
42
78
  onLoadMore: () => void;
43
79
  onTyping: (isTyping: boolean) => void;