@repzo/widget-react-native 1.3.0 → 1.3.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 (117) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +8 -4
  3. package/lib/commonjs/api/client.js +40 -0
  4. package/lib/commonjs/api/client.js.map +1 -1
  5. package/lib/commonjs/attachments/pickers.js +80 -23
  6. package/lib/commonjs/attachments/pickers.js.map +1 -1
  7. package/lib/commonjs/i18n.js +2 -0
  8. package/lib/commonjs/i18n.js.map +1 -1
  9. package/lib/commonjs/locales.js +1 -0
  10. package/lib/commonjs/locales.js.map +1 -1
  11. package/lib/commonjs/state/widget-store.js +216 -40
  12. package/lib/commonjs/state/widget-store.js.map +1 -1
  13. package/lib/commonjs/types.js.map +1 -1
  14. package/lib/commonjs/ui/WidgetProvider.js +7 -3
  15. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  16. package/lib/commonjs/ui/components/ImageViewer.js +3 -2
  17. package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
  18. package/lib/commonjs/ui/components/icons.js +22 -16
  19. package/lib/commonjs/ui/components/icons.js.map +1 -1
  20. package/lib/commonjs/ui/screens/chat/ChatView.js +36 -14
  21. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  22. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +12 -2
  23. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  24. package/lib/commonjs/ui/styles/theme.js +1 -0
  25. package/lib/commonjs/ui/styles/theme.js.map +1 -1
  26. package/lib/commonjs/ui/utils/conversations.js +17 -1
  27. package/lib/commonjs/ui/utils/conversations.js.map +1 -1
  28. package/lib/module/api/client.js +40 -0
  29. package/lib/module/api/client.js.map +1 -1
  30. package/lib/module/attachments/pickers.js +80 -23
  31. package/lib/module/attachments/pickers.js.map +1 -1
  32. package/lib/module/i18n.js +2 -0
  33. package/lib/module/i18n.js.map +1 -1
  34. package/lib/module/locales.js +1 -0
  35. package/lib/module/locales.js.map +1 -1
  36. package/lib/module/state/widget-store.js +216 -40
  37. package/lib/module/state/widget-store.js.map +1 -1
  38. package/lib/module/types.js.map +1 -1
  39. package/lib/module/ui/WidgetProvider.js +7 -3
  40. package/lib/module/ui/WidgetProvider.js.map +1 -1
  41. package/lib/module/ui/components/ImageViewer.js +3 -2
  42. package/lib/module/ui/components/ImageViewer.js.map +1 -1
  43. package/lib/module/ui/components/icons.js +21 -15
  44. package/lib/module/ui/components/icons.js.map +1 -1
  45. package/lib/module/ui/screens/chat/ChatView.js +38 -16
  46. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  47. package/lib/module/ui/screens/chat/ChatView.styles.js +12 -2
  48. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  49. package/lib/module/ui/styles/theme.js +1 -0
  50. package/lib/module/ui/styles/theme.js.map +1 -1
  51. package/lib/module/ui/utils/conversations.js +15 -1
  52. package/lib/module/ui/utils/conversations.js.map +1 -1
  53. package/lib/typescript/commonjs/api/client.d.ts +27 -1
  54. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/attachments/pickers.d.ts +22 -10
  56. package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/i18n.d.ts +1 -0
  58. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/state/widget-store.d.ts +27 -3
  61. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/types.d.ts +17 -0
  63. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/ui/components/icons.d.ts +2 -1
  67. package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +1 -1
  69. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +10 -2
  71. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  72. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +4 -0
  73. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/ui/styles/theme.d.ts +2 -0
  75. package/lib/typescript/commonjs/ui/styles/theme.d.ts.map +1 -1
  76. package/lib/typescript/commonjs/ui/utils/conversations.d.ts +10 -2
  77. package/lib/typescript/commonjs/ui/utils/conversations.d.ts.map +1 -1
  78. package/lib/typescript/module/api/client.d.ts +27 -1
  79. package/lib/typescript/module/api/client.d.ts.map +1 -1
  80. package/lib/typescript/module/attachments/pickers.d.ts +22 -10
  81. package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
  82. package/lib/typescript/module/i18n.d.ts +1 -0
  83. package/lib/typescript/module/i18n.d.ts.map +1 -1
  84. package/lib/typescript/module/locales.d.ts.map +1 -1
  85. package/lib/typescript/module/state/widget-store.d.ts +27 -3
  86. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  87. package/lib/typescript/module/types.d.ts +17 -0
  88. package/lib/typescript/module/types.d.ts.map +1 -1
  89. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  90. package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
  91. package/lib/typescript/module/ui/components/icons.d.ts +2 -1
  92. package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
  93. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +1 -1
  94. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  95. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +10 -2
  96. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  97. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +4 -0
  98. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  99. package/lib/typescript/module/ui/styles/theme.d.ts +2 -0
  100. package/lib/typescript/module/ui/styles/theme.d.ts.map +1 -1
  101. package/lib/typescript/module/ui/utils/conversations.d.ts +10 -2
  102. package/lib/typescript/module/ui/utils/conversations.d.ts.map +1 -1
  103. package/package.json +1 -1
  104. package/src/api/client.ts +66 -2
  105. package/src/attachments/pickers.ts +87 -25
  106. package/src/i18n.ts +2 -0
  107. package/src/locales.ts +1 -0
  108. package/src/state/widget-store.ts +210 -33
  109. package/src/types.ts +17 -0
  110. package/src/ui/WidgetProvider.tsx +15 -4
  111. package/src/ui/components/ImageViewer.tsx +10 -6
  112. package/src/ui/components/icons.tsx +10 -12
  113. package/src/ui/screens/chat/ChatView.styles.ts +12 -2
  114. package/src/ui/screens/chat/ChatView.tsx +45 -22
  115. package/src/ui/screens/chat/ChatView.types.ts +4 -0
  116. package/src/ui/styles/theme.ts +6 -0
  117. package/src/ui/utils/conversations.ts +16 -2
package/src/types.ts CHANGED
@@ -64,6 +64,12 @@ export interface WidgetConfig {
64
64
  proactiveFrequency: 'session' | 'visitor';
65
65
  proactiveOpenMode: 'home' | 'new_message';
66
66
  attachmentsEnabled: boolean;
67
+ /**
68
+ * Largest file a visitor may attach. Set by servers that take uploads
69
+ * (`/api/widget/uploads`); absent on older ones, which only take files
70
+ * inline in the message.
71
+ */
72
+ maxAttachmentBytes?: number;
67
73
  analyticsEnabled: boolean;
68
74
  toursEnabled: boolean;
69
75
  defaultLocale: string;
@@ -94,7 +100,10 @@ export interface WidgetAttachment {
94
100
  */
95
101
  export interface OutgoingAttachment {
96
102
  name: string;
103
+ /** A base64 data URL; empty when `fileUri` is set. */
97
104
  url: string;
105
+ /** A local file uploaded before the message is sent (servers with `maxAttachmentBytes`). */
106
+ fileUri?: string;
98
107
  contentType: string;
99
108
  /** Decoded bytes. */
100
109
  size: number;
@@ -136,6 +145,14 @@ export interface Message {
136
145
  createdAt: string;
137
146
  /** Set when a teammate deleted the message; the content fields are then empty. */
138
147
  deletedAt?: string | null;
148
+ /**
149
+ * On the visitor's own messages: the id this device sent it under, which
150
+ * stays the same when a failed message is retried. Lets a stored message
151
+ * replace its pending or failed bubble. Only newer Workstation deployments
152
+ * send it.
153
+ * @since 1.3.1
154
+ */
155
+ clientMessageId?: string | null;
139
156
  }
140
157
 
141
158
  export interface OutboundMessage {
@@ -25,11 +25,12 @@ import {
25
25
  MAX_ATTACHMENT_BYTES,
26
26
  MAX_ATTACHMENTS,
27
27
  pickAttachments,
28
+ type DraftAttachment,
28
29
  } from '../attachments/pickers';
29
30
  import { controller } from '../controller';
30
31
  import { buildLabels, createTranslator, formatLabel, localizeWelcomeText } from '../i18n';
31
32
  import { useWidgetState } from '../state/use-widget-state';
32
- import type { AgentUiAction, OutgoingAttachment, PostInteractionKind, WidgetSpaceType } from '../types';
33
+ import type { AgentUiAction, AttachmentSource, OutgoingAttachment, PostInteractionKind, WidgetSpaceType } from '../types';
33
34
  import { WidgetErrorBoundary } from './components/ErrorBoundary';
34
35
  import { AlertIcon } from './components/icons';
35
36
  import { InAppNotification } from './components/InAppNotification';
@@ -163,13 +164,15 @@ export function RepzoWidgetProvider({
163
164
  file: labels.attachChooseFile,
164
165
  cancel: labels.cancel,
165
166
  remove: labels.removeAttachment,
166
- tooLarge: formatLabel(labels.attachmentTooLarge, { max: formatBytes(MAX_ATTACHMENT_BYTES) }),
167
+ tooLarge: formatLabel(labels.attachmentTooLarge, {
168
+ max: formatBytes(theme.maxAttachmentBytes ?? MAX_ATTACHMENT_BYTES),
169
+ }),
167
170
  tooMany: formatLabel(labels.attachmentTooMany, { count: MAX_ATTACHMENTS }),
168
171
  tooLargeTogether: labels.attachmentsTooLargeTogether,
169
172
  failed: labels.attachmentFailed,
170
173
  cameraDenied: labels.cameraPermissionDenied,
171
174
  }),
172
- [labels]
175
+ [labels, theme.maxAttachmentBytes]
173
176
  );
174
177
 
175
178
  // Stable across renders so each message bubble only re-renders when its
@@ -182,6 +185,7 @@ export function RepzoWidgetProvider({
182
185
  emptyConversation: labels.emptyConversation,
183
186
  messagePlaceholder: labels.messagePlaceholder,
184
187
  failedToSend: labels.failedToSend,
188
+ tapToRetry: labels.tapToRetry,
185
189
  openAttachment: labels.openAttachment,
186
190
  thinking: labels.thinking,
187
191
  team: labels.team,
@@ -238,6 +242,11 @@ export function RepzoWidgetProvider({
238
242
  const attachmentSources = theme.attachmentsEnabled ? getAttachmentSources() : [];
239
243
 
240
244
  const handleClose = useCallback(() => store.hide(), [store]);
245
+ const handlePickAttachments = useCallback(
246
+ (source: AttachmentSource, current: DraftAttachment[]) =>
247
+ pickAttachments(source, current, theme.maxAttachmentBytes),
248
+ [theme.maxAttachmentBytes]
249
+ );
241
250
 
242
251
  const handleOpenNotification = useCallback(
243
252
  (conversationId: string) => void store.openConversation(conversationId),
@@ -304,6 +313,7 @@ export function RepzoWidgetProvider({
304
313
  store.sendMessage(body, attachments ?? [], action),
305
314
  [store]
306
315
  );
316
+ const handleRetryMessage = useCallback((messageId: string) => store.retryMessage(messageId), [store]);
307
317
 
308
318
  const handleIdentifyVisitor = useCallback(
309
319
  (data: { email?: string; name?: string; phone?: string }) => store.identifyVisitor(data),
@@ -442,8 +452,9 @@ export function RepzoWidgetProvider({
442
452
  offlineMessage={theme.offlineMessage}
443
453
  labels={chatLabels}
444
454
  attachmentSources={attachmentSources}
445
- onPickAttachments={pickAttachments}
455
+ onPickAttachments={handlePickAttachments}
446
456
  onSendMessage={handleSendMessage}
457
+ onRetryMessage={handleRetryMessage}
447
458
  onIdentifyVisitor={handleIdentifyVisitor}
448
459
  onRateConversation={handleRateConversation}
449
460
  onLoadMore={handleLoadMore}
@@ -17,6 +17,7 @@ import {
17
17
 
18
18
  import { useWidgetInsets } from '../hooks/use-widget-insets';
19
19
  import { fontSize, palette, spacing } from '../styles/theme';
20
+ import { isHttpUrl } from '../utils/safe-url';
20
21
  import { CloseIcon } from './icons';
21
22
  import { ZoomableImage } from './ZoomableImage';
22
23
 
@@ -68,12 +69,15 @@ export function ImageViewer({ image, labels, onClose, onOpenExternally }: ImageV
68
69
  {image && state === 'failed' ? (
69
70
  <View style={[StyleSheet.absoluteFill, styles.center, styles.failed]}>
70
71
  <Text style={styles.failedText}>{labels.loadFailed}</Text>
71
- <Pressable
72
- onPress={() => onOpenExternally(image.url)}
73
- accessibilityRole="button"
74
- style={({ pressed }) => [styles.externalButton, pressed && styles.pressed]}>
75
- <Text style={styles.externalLabel}>{labels.openExternally}</Text>
76
- </Pressable>
72
+ {/* A visitor's local photo has no web copy to open yet. */}
73
+ {isHttpUrl(image.url) ? (
74
+ <Pressable
75
+ onPress={() => onOpenExternally(image.url)}
76
+ accessibilityRole="button"
77
+ style={({ pressed }) => [styles.externalButton, pressed && styles.pressed]}>
78
+ <Text style={styles.externalLabel}>{labels.openExternally}</Text>
79
+ </Pressable>
80
+ ) : null}
77
81
  </View>
78
82
  ) : null}
79
83
 
@@ -322,20 +322,18 @@ export function AlertIcon({ size = 16, color = palette.warningIcon, style }: Ico
322
322
  );
323
323
  }
324
324
 
325
- export function PaperclipIcon({ size = 18, color = palette.textMuted, style }: IconProps) {
326
- const thickness = Math.max(1.5, size * 0.1);
325
+ /** Two upright crossed bars — the composer's attach button. */
326
+ export function PlusIcon({ size = 18, color = palette.textMuted, style }: IconProps) {
327
+ const thickness = Math.max(2, size * 0.1);
328
+ const bar: ViewStyle = {
329
+ position: 'absolute',
330
+ borderRadius: thickness / 2,
331
+ backgroundColor: color,
332
+ };
327
333
  return (
328
334
  <View style={[{ width: size, height: size }, styles.center, style]}>
329
- <View
330
- style={{
331
- width: size * 0.42,
332
- height: size * 0.82,
333
- borderRadius: size * 0.21,
334
- borderWidth: thickness,
335
- borderColor: color,
336
- transform: [{ rotate: '35deg' }],
337
- }}
338
- />
335
+ <View style={[bar, { width: size * 0.8, height: thickness }]} />
336
+ <View style={[bar, { width: thickness, height: size * 0.8 }]} />
339
337
  </View>
340
338
  );
341
339
  }
@@ -212,9 +212,19 @@ export const styles = StyleSheet.create({
212
212
  height: 140,
213
213
  borderRadius: radius.md,
214
214
  },
215
+ // Follows the bubble's side. Under the visitor's it lines up with
216
+ // `receiptText`, which it replaces on a failed message.
217
+ failedRow: {
218
+ flexDirection: 'row',
219
+ alignItems: 'center',
220
+ gap: 4,
221
+ marginTop: 3,
222
+ marginEnd: spacing.xs,
223
+ },
215
224
  failedText: {
216
- fontSize: fontSize.caption,
217
- marginTop: spacing.xs,
225
+ fontSize: 11,
226
+ fontWeight: '600',
227
+ color: palette.danger,
218
228
  },
219
229
  deletedBubble: {
220
230
  maxWidth: '80%',
@@ -49,14 +49,14 @@ import { ImageViewer } from '../../components/ImageViewer';
49
49
  import { getRatingRequest, RatingCard } from '../../components/RatingCard';
50
50
  import { WidgetErrorBoundary } from '../../components/ErrorBoundary';
51
51
  import { GradientBackground } from '../../components/Gradient';
52
- import { ArrowUpIcon, ChatOutlineIcon, ChevronIcon, ClockIcon, PaperclipIcon } from '../../components/icons';
52
+ import { AlertIcon, ArrowUpIcon, ChatOutlineIcon, ChevronIcon, ClockIcon, PlusIcon } from '../../components/icons';
53
53
  import { TeamAvatars } from '../../components/TeamAvatars';
54
54
  import { Markdown } from '../../components/Markdown';
55
55
  import { useReduceMotion } from '../../hooks/use-reduce-motion';
56
56
  import { useTopInset } from '../../hooks/use-top-inset';
57
57
  import { globalStyles } from '../../styles/global';
58
58
  import { contrastingTextColor, mixHex, palette, spacing, withAlpha } from '../../styles/theme';
59
- import { formatClockTime, formatDayLabel, isSameDay } from '../../utils/conversations';
59
+ import { canRetryMessage, formatClockTime, formatDayLabel, isSameDay } from '../../utils/conversations';
60
60
  import { stripHtml } from '../../utils/html';
61
61
  import { isHttpUrl, openExternalUrl } from '../../utils/safe-url';
62
62
  import { isPlayableAudio } from '../../../audio/voice';
@@ -87,6 +87,7 @@ export function ChatView({
87
87
  attachmentSources = [],
88
88
  onPickAttachments,
89
89
  onSendMessage,
90
+ onRetryMessage,
90
91
  onIdentifyVisitor,
91
92
  onRateConversation,
92
93
  defaultLocale,
@@ -268,6 +269,7 @@ export function ChatView({
268
269
  sent.map((draft) => ({
269
270
  name: draft.name,
270
271
  url: draft.dataUrl,
272
+ fileUri: draft.fileUri,
271
273
  contentType: draft.contentType,
272
274
  size: draft.size,
273
275
  previewUrl: draft.previewUri,
@@ -325,6 +327,16 @@ export function ChatView({
325
327
  [onSendMessage]
326
328
  );
327
329
 
330
+ // The retried bubble moves to the end of the thread; follow it there.
331
+ // A second failure shows on the bubble again.
332
+ const handleRetryMessage = useCallback(
333
+ (messageId: string) => {
334
+ onRetryMessage?.(messageId).catch(() => {});
335
+ scrollToBottom();
336
+ },
337
+ [onRetryMessage, scrollToBottom]
338
+ );
339
+
328
340
  // Recomputed only when the list changes — never on a keystroke.
329
341
  const thread = useMemo(() => deriveThread(messages), [messages]);
330
342
  const locales = useMemo(() => [locale, defaultLocale], [locale, defaultLocale]);
@@ -521,6 +533,7 @@ export function ChatView({
521
533
  onAgentUiAction={
522
534
  thread.agentBlocksShown.has(message.id) ? handleAgentUiAction : undefined
523
535
  }
536
+ onRetry={onRetryMessage && canRetryMessage(message) ? handleRetryMessage : undefined}
524
537
  />
525
538
  </WidgetErrorBoundary>
526
539
  </Fragment>
@@ -592,7 +605,7 @@ export function ChatView({
592
605
  styles.attachButton,
593
606
  (pressed || sending || picking) && globalStyles.pressed,
594
607
  ]}>
595
- <PaperclipIcon size={20} color={palette.textMuted} />
608
+ <PlusIcon size={20} color={palette.textMuted} />
596
609
  </Pressable>
597
610
  ) : null}
598
611
  <TextInput
@@ -663,6 +676,7 @@ const MessageBubble = memo(function MessageBubble({
663
676
  onOpenImage,
664
677
  onRateConversation,
665
678
  locales,
679
+ onRetry,
666
680
  }: {
667
681
  message: Message;
668
682
  color: string;
@@ -675,6 +689,8 @@ const MessageBubble = memo(function MessageBubble({
675
689
  onOpenImage: (image: { url: string; name: string }) => void;
676
690
  onRateConversation?: ChatViewProps['onRateConversation'];
677
691
  locales: Array<string | null | undefined>;
692
+ /** Re-sends this message when it failed. */
693
+ onRetry?: (messageId: string) => void;
678
694
  }) {
679
695
  const joinedName = message.metadata?.event === 'agent_joined' ? message.metadata?.agentName : null;
680
696
  if (message.messageType === 'system' && typeof joinedName === 'string' && joinedName) {
@@ -772,16 +788,6 @@ const MessageBubble = memo(function MessageBubble({
772
788
  </View>
773
789
  ) : null}
774
790
 
775
- {message.status === 'failed' ? (
776
- <Text
777
- style={[
778
- styles.failedText,
779
- { color: isVisitor ? withAlpha(palette.white, 0.7) : palette.danger },
780
- ]}>
781
- {labels.failedToSend}
782
- </Text>
783
- ) : null}
784
-
785
791
  {timeLabel ? (
786
792
  <Text
787
793
  style={[
@@ -795,6 +801,22 @@ const MessageBubble = memo(function MessageBubble({
795
801
 
796
802
  {receiptLabel ? <Text style={styles.receiptText}>{receiptLabel}</Text> : null}
797
803
 
804
+ {/* Under the bubble, where Sent/Seen would be: in the danger color it
805
+ reads on any brand color, and the whole line is the retry target. */}
806
+ {message.status === 'failed' ? (
807
+ <Pressable
808
+ onPress={onRetry ? () => onRetry(message.id) : undefined}
809
+ disabled={!onRetry}
810
+ accessibilityRole={onRetry ? 'button' : 'text'}
811
+ hitSlop={8}
812
+ style={({ pressed }) => [styles.failedRow, pressed && globalStyles.pressed]}>
813
+ <AlertIcon size={12} color={palette.danger} />
814
+ <Text style={styles.failedText}>
815
+ {onRetry ? `${labels.failedToSend} · ${labels.tapToRetry}` : labels.failedToSend}
816
+ </Text>
817
+ </Pressable>
818
+ ) : null}
819
+
798
820
  {agentUiBlocks.length > 0 && onAgentUiAction ? (
799
821
  <AgentUiBlocks
800
822
  blocks={agentUiBlocks}
@@ -839,18 +861,20 @@ function AttachmentPreview({
839
861
  onOpenImage: (image: { url: string; name: string }) => void;
840
862
  }) {
841
863
  // `url` is empty when the server sent a non-http(s) URL (see resolveUrl).
842
- // An unsent attachment shows its local file (or data URL): it renders, but
843
- // there is nothing to open until the server copy arrives.
864
+ // The visitor's own attachment points at the picked file until the server
865
+ // copy replaces it (or is empty without one).
844
866
  const [imageFailed, setImageFailed] = useState(false);
845
867
  const [audioFailed, setAudioFailed] = useState(false);
846
868
  const isImage = isImageAttachment(attachment.contentType) && !!attachment.url && !imageFailed;
847
869
  const openable = isHttpUrl(attachment.url);
848
- // Images open in the in-app viewer; other files (PDF, sheets) need the OS.
849
- const open = !openable
850
- ? undefined
851
- : isImage
852
- ? () => onOpenImage({ url: attachment.url, name: attachment.name })
853
- : () => void openExternalUrl(attachment.url);
870
+ // Images open in the in-app viewer, a local photo too since it already
871
+ // renders here. Other files (PDF, sheets) need the OS, which can only open
872
+ // the server copy.
873
+ const open = isImage
874
+ ? () => onOpenImage({ url: attachment.url, name: attachment.name })
875
+ : openable
876
+ ? () => void openExternalUrl(attachment.url)
877
+ : undefined;
854
878
 
855
879
  // Voice notes from the team (and any other audio) play in place, like the
856
880
  // web Messenger — when the app has expo-audio; otherwise the file row below.
@@ -873,7 +897,6 @@ function AttachmentPreview({
873
897
  return (
874
898
  <Pressable
875
899
  onPress={open}
876
- disabled={!openable}
877
900
  accessibilityRole="imagebutton"
878
901
  accessibilityLabel={attachment.name}>
879
902
  <Image
@@ -21,6 +21,8 @@ export interface ChatLabels {
21
21
  emptyConversation: string;
22
22
  messagePlaceholder: string;
23
23
  failedToSend: string;
24
+ /** After `failedToSend` on a bubble that can be re-sent. */
25
+ tapToRetry: string;
24
26
  thinking: string;
25
27
  team: string;
26
28
  statusOnline: string;
@@ -104,6 +106,8 @@ export interface ChatViewProps {
104
106
  attachments?: OutgoingAttachment[],
105
107
  action?: AgentUiAction
106
108
  ) => Promise<void>;
109
+ /** Re-sends a failed bubble; rejects when it fails again. Absent: no retry. */
110
+ onRetryMessage?: (messageId: string) => Promise<void>;
107
111
  onIdentifyVisitor?: (data: { email?: string; name?: string; phone?: string }) => Promise<void>;
108
112
  /** Answers a rating card; rejects with the server error (409 locked, 410 expired). */
109
113
  onRateConversation?: (
@@ -68,6 +68,8 @@ export interface WidgetTheme {
68
68
  allowNewConversations: boolean;
69
69
  showPreviousConversations: boolean;
70
70
  attachmentsEnabled: boolean;
71
+ /** The server's upload cap; null when it only takes files inline in the message. */
72
+ maxAttachmentBytes: number | null;
71
73
  preChatEnabled: boolean;
72
74
  }
73
75
 
@@ -94,6 +96,10 @@ export function buildTheme(config: WidgetConfig | null): WidgetTheme {
94
96
  allowNewConversations: config?.allowNewConversations ?? true,
95
97
  showPreviousConversations: config?.showPreviousConversations ?? true,
96
98
  attachmentsEnabled: config?.attachmentsEnabled ?? true,
99
+ maxAttachmentBytes:
100
+ typeof config?.maxAttachmentBytes === 'number' && config.maxAttachmentBytes > 0
101
+ ? config.maxAttachmentBytes
102
+ : null,
97
103
  preChatEnabled: config?.preChatEnabled ?? false,
98
104
  };
99
105
  }
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Conversation selectors and formatting shared by the Home and Messages
3
- * spaces, so the two can never disagree about ordering or wording.
3
+ * spaces (and the store and chat thread, for message helpers), so they can
4
+ * never disagree about ordering or wording.
4
5
  * Pure and free of React so they stay trivial to test.
5
6
  */
6
7
 
7
8
  import { formatLabel } from '../../i18n';
8
- import type { Conversation } from '../../types';
9
+ import type { Conversation, Message } from '../../types';
9
10
 
10
11
  export interface TeamAgent {
11
12
  name: string | null;
@@ -120,3 +121,16 @@ export function formatDayLabel(
120
121
  return date.toDateString();
121
122
  }
122
123
  }
124
+
125
+ /** Optimistic visitor bubbles the server hasn't acknowledged (pending/failed). */
126
+ export function isLocalMessage(message: Message): boolean {
127
+ return message.id.startsWith('temp-');
128
+ }
129
+
130
+ /**
131
+ * The visitor's own bubble that never reached the server. A `failed` status
132
+ * the server sets on any other message isn't something this device can re-send.
133
+ */
134
+ export function canRetryMessage(message: Message): boolean {
135
+ return message.status === 'failed' && message.direction === 'inbound' && isLocalMessage(message);
136
+ }