@repzo/widget-react-native 1.0.0 → 1.0.4

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 (153) hide show
  1. package/README.md +167 -74
  2. package/lib/commonjs/api/client.js +3 -1
  3. package/lib/commonjs/api/client.js.map +1 -1
  4. package/lib/commonjs/api/realtime.js +2 -0
  5. package/lib/commonjs/api/realtime.js.map +1 -1
  6. package/lib/commonjs/attachments/pickers.js +493 -0
  7. package/lib/commonjs/attachments/pickers.js.map +1 -0
  8. package/lib/commonjs/i18n.js +45 -0
  9. package/lib/commonjs/i18n.js.map +1 -1
  10. package/lib/commonjs/index.js.map +1 -1
  11. package/lib/commonjs/locales.js +21 -0
  12. package/lib/commonjs/locales.js.map +1 -1
  13. package/lib/commonjs/state/widget-store.js +256 -18
  14. package/lib/commonjs/state/widget-store.js.map +1 -1
  15. package/lib/commonjs/types.js +16 -0
  16. package/lib/commonjs/types.js.map +1 -1
  17. package/lib/commonjs/ui/WidgetProvider.js +70 -10
  18. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  19. package/lib/commonjs/ui/components/AttachmentChips.js +135 -0
  20. package/lib/commonjs/ui/components/AttachmentChips.js.map +1 -0
  21. package/lib/commonjs/ui/components/AttachmentMenu.js +125 -0
  22. package/lib/commonjs/ui/components/AttachmentMenu.js.map +1 -0
  23. package/lib/commonjs/ui/components/ImageViewer.js +181 -0
  24. package/lib/commonjs/ui/components/ImageViewer.js.map +1 -0
  25. package/lib/commonjs/ui/components/InAppNotification.js +210 -0
  26. package/lib/commonjs/ui/components/InAppNotification.js.map +1 -0
  27. package/lib/commonjs/ui/components/TeamAvatars.js +63 -0
  28. package/lib/commonjs/ui/components/TeamAvatars.js.map +1 -0
  29. package/lib/commonjs/ui/components/icons.js +50 -0
  30. package/lib/commonjs/ui/components/icons.js.map +1 -1
  31. package/lib/commonjs/ui/screens/chat/ChatView.js +303 -24
  32. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  33. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +70 -0
  34. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  35. package/lib/module/api/client.js +3 -1
  36. package/lib/module/api/client.js.map +1 -1
  37. package/lib/module/api/realtime.js +2 -0
  38. package/lib/module/api/realtime.js.map +1 -1
  39. package/lib/module/attachments/pickers.js +479 -0
  40. package/lib/module/attachments/pickers.js.map +1 -0
  41. package/lib/module/i18n.js +44 -0
  42. package/lib/module/i18n.js.map +1 -1
  43. package/lib/module/index.js.map +1 -1
  44. package/lib/module/locales.js +21 -0
  45. package/lib/module/locales.js.map +1 -1
  46. package/lib/module/state/widget-store.js +256 -18
  47. package/lib/module/state/widget-store.js.map +1 -1
  48. package/lib/module/types.js +16 -0
  49. package/lib/module/types.js.map +1 -1
  50. package/lib/module/ui/WidgetProvider.js +71 -11
  51. package/lib/module/ui/WidgetProvider.js.map +1 -1
  52. package/lib/module/ui/components/AttachmentChips.js +131 -0
  53. package/lib/module/ui/components/AttachmentChips.js.map +1 -0
  54. package/lib/module/ui/components/AttachmentMenu.js +120 -0
  55. package/lib/module/ui/components/AttachmentMenu.js.map +1 -0
  56. package/lib/module/ui/components/ImageViewer.js +177 -0
  57. package/lib/module/ui/components/ImageViewer.js.map +1 -0
  58. package/lib/module/ui/components/InAppNotification.js +208 -0
  59. package/lib/module/ui/components/InAppNotification.js.map +1 -0
  60. package/lib/module/ui/components/TeamAvatars.js +59 -0
  61. package/lib/module/ui/components/TeamAvatars.js.map +1 -0
  62. package/lib/module/ui/components/icons.js +49 -0
  63. package/lib/module/ui/components/icons.js.map +1 -1
  64. package/lib/module/ui/screens/chat/ChatView.js +306 -27
  65. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  66. package/lib/module/ui/screens/chat/ChatView.styles.js +70 -0
  67. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  68. package/lib/typescript/commonjs/api/client.d.ts +8 -2
  69. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/api/realtime.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/attachments/pickers.d.ts +81 -0
  72. package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -0
  73. package/lib/typescript/commonjs/i18n.d.ts +23 -0
  74. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/index.d.ts +1 -1
  76. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  78. package/lib/typescript/commonjs/state/widget-store.d.ts +40 -2
  79. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/types.d.ts +88 -1
  81. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  83. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts +16 -0
  84. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts.map +1 -0
  85. package/lib/typescript/commonjs/ui/components/AttachmentMenu.d.ts +28 -0
  86. package/lib/typescript/commonjs/ui/components/AttachmentMenu.d.ts.map +1 -0
  87. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +23 -0
  88. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -0
  89. package/lib/typescript/commonjs/ui/components/InAppNotification.d.ts +28 -0
  90. package/lib/typescript/commonjs/ui/components/InAppNotification.d.ts.map +1 -0
  91. package/lib/typescript/commonjs/ui/components/TeamAvatars.d.ts +17 -0
  92. package/lib/typescript/commonjs/ui/components/TeamAvatars.d.ts.map +1 -0
  93. package/lib/typescript/commonjs/ui/components/icons.d.ts +2 -0
  94. package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +3 -2
  96. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +69 -0
  98. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +41 -2
  100. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  101. package/lib/typescript/module/api/client.d.ts +8 -2
  102. package/lib/typescript/module/api/client.d.ts.map +1 -1
  103. package/lib/typescript/module/api/realtime.d.ts.map +1 -1
  104. package/lib/typescript/module/attachments/pickers.d.ts +81 -0
  105. package/lib/typescript/module/attachments/pickers.d.ts.map +1 -0
  106. package/lib/typescript/module/i18n.d.ts +23 -0
  107. package/lib/typescript/module/i18n.d.ts.map +1 -1
  108. package/lib/typescript/module/index.d.ts +1 -1
  109. package/lib/typescript/module/index.d.ts.map +1 -1
  110. package/lib/typescript/module/locales.d.ts.map +1 -1
  111. package/lib/typescript/module/state/widget-store.d.ts +40 -2
  112. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  113. package/lib/typescript/module/types.d.ts +88 -1
  114. package/lib/typescript/module/types.d.ts.map +1 -1
  115. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  116. package/lib/typescript/module/ui/components/AttachmentChips.d.ts +16 -0
  117. package/lib/typescript/module/ui/components/AttachmentChips.d.ts.map +1 -0
  118. package/lib/typescript/module/ui/components/AttachmentMenu.d.ts +28 -0
  119. package/lib/typescript/module/ui/components/AttachmentMenu.d.ts.map +1 -0
  120. package/lib/typescript/module/ui/components/ImageViewer.d.ts +23 -0
  121. package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -0
  122. package/lib/typescript/module/ui/components/InAppNotification.d.ts +28 -0
  123. package/lib/typescript/module/ui/components/InAppNotification.d.ts.map +1 -0
  124. package/lib/typescript/module/ui/components/TeamAvatars.d.ts +17 -0
  125. package/lib/typescript/module/ui/components/TeamAvatars.d.ts.map +1 -0
  126. package/lib/typescript/module/ui/components/icons.d.ts +2 -0
  127. package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
  128. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +3 -2
  129. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  130. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +69 -0
  131. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  132. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +41 -2
  133. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  134. package/metro.js +17 -32
  135. package/package.json +18 -1
  136. package/src/api/client.ts +5 -3
  137. package/src/api/realtime.ts +2 -0
  138. package/src/attachments/pickers.ts +515 -0
  139. package/src/i18n.ts +48 -0
  140. package/src/index.tsx +4 -0
  141. package/src/locales.ts +21 -0
  142. package/src/state/widget-store.ts +256 -19
  143. package/src/types.ts +95 -0
  144. package/src/ui/WidgetProvider.tsx +90 -9
  145. package/src/ui/components/AttachmentChips.tsx +135 -0
  146. package/src/ui/components/AttachmentMenu.tsx +132 -0
  147. package/src/ui/components/ImageViewer.tsx +178 -0
  148. package/src/ui/components/InAppNotification.tsx +225 -0
  149. package/src/ui/components/TeamAvatars.tsx +58 -0
  150. package/src/ui/components/icons.tsx +37 -0
  151. package/src/ui/screens/chat/ChatView.styles.ts +70 -0
  152. package/src/ui/screens/chat/ChatView.tsx +345 -35
  153. package/src/ui/screens/chat/ChatView.types.ts +51 -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,35 @@ 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, ClockIcon, PaperclipIcon } from '../../components/icons';
51
+ import { TeamAvatars } from '../../components/TeamAvatars';
34
52
  import { Markdown } from '../../components/Markdown';
35
53
  import { useReduceMotion } from '../../hooks/use-reduce-motion';
36
54
  import { globalStyles } from '../../styles/global';
37
55
  import { contrastingTextColor, mixHex, palette, withAlpha } from '../../styles/theme';
38
56
  import { formatClockTime, formatDayLabel, isSameDay } from '../../utils/conversations';
39
57
  import { stripHtml } from '../../utils/html';
40
- import { openExternalUrl } from '../../utils/safe-url';
58
+ import { isHttpUrl, openExternalUrl } from '../../utils/safe-url';
41
59
  import { backDirection, isRTL } from '../../utils/rtl';
42
60
  import { styles } from './ChatView.styles';
43
61
  import type { ChatLabels, ChatViewProps } from './ChatView.types';
@@ -50,11 +68,17 @@ export function ChatView({
50
68
  color,
51
69
  agentName,
52
70
  agentAvatar,
71
+ agentOnline,
53
72
  isOnline,
73
+ teamName,
74
+ teamAvatars = [],
75
+ replyTimeLabel,
54
76
  locale,
55
77
  prefillText,
56
78
  offlineMessage,
57
79
  labels,
80
+ attachmentSources = [],
81
+ onPickAttachments,
58
82
  onSendMessage,
59
83
  onIdentifyVisitor,
60
84
  onLoadMore,
@@ -64,20 +88,41 @@ export function ChatView({
64
88
  }: ChatViewProps) {
65
89
  const [input, setInput] = useState(prefillText || '');
66
90
  const [composerFocused, setComposerFocused] = useState(false);
91
+ const [drafts, setDrafts] = useState<DraftAttachment[]>([]);
92
+ const [menuOpen, setMenuOpen] = useState(false);
93
+ const [viewerImage, setViewerImage] = useState<{ url: string; name: string } | null>(null);
94
+ const [picking, setPicking] = useState(false);
95
+ // Only attachment sends hold the composer; text sends stay fire-and-forget.
96
+ const [sending, setSending] = useState(false);
97
+ const [notice, setNotice] = useState<string | null>(null);
67
98
  const reduceMotion = useReduceMotion();
68
99
  const scrollRef = useRef<ScrollView>(null);
69
100
  const typingStopTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
101
+ const noticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
102
+ const pickerTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
103
+ const mounted = useRef(false);
70
104
 
71
105
  useEffect(() => {
72
106
  if (prefillText) setInput(prefillText);
73
107
  }, [prefillText]);
74
108
 
75
- useEffect(
76
- () => () => {
109
+ // The provider keys this screen by the store's thread generation. Pending
110
+ // picks and sends belong to this mount, even before a draft gets a server ID.
111
+ useEffect(() => {
112
+ mounted.current = true;
113
+ return () => {
114
+ mounted.current = false;
77
115
  if (typingStopTimer.current) clearTimeout(typingStopTimer.current);
78
- },
79
- []
80
- );
116
+ if (noticeTimer.current) clearTimeout(noticeTimer.current);
117
+ if (pickerTimer.current) clearTimeout(pickerTimer.current);
118
+ };
119
+ }, []);
120
+
121
+ const showNotice = useCallback((message: string | null) => {
122
+ if (noticeTimer.current) clearTimeout(noticeTimer.current);
123
+ setNotice(message);
124
+ if (message) noticeTimer.current = setTimeout(() => setNotice(null), 5000);
125
+ }, []);
81
126
 
82
127
  const scrollToBottom = useCallback((animated = true) => {
83
128
  scrollRef.current?.scrollToEnd({ animated });
@@ -122,16 +167,120 @@ export function ChatView({
122
167
  [scrollToBottom]
123
168
  );
124
169
 
125
- const canSend = input.trim().length > 0;
170
+ const showAttach = attachmentSources.length > 0 && !!onPickAttachments;
171
+ const canSend = !sending && (input.trim().length > 0 || drafts.length > 0);
172
+
173
+ const problemLabel = (problem: AttachmentProblem | null): string | null => {
174
+ switch (problem) {
175
+ case 'tooMany':
176
+ return labels.attach.tooMany;
177
+ case 'tooLarge':
178
+ return labels.attach.tooLarge;
179
+ case 'tooLargeTogether':
180
+ return labels.attach.tooLargeTogether;
181
+ case 'cameraDenied':
182
+ return labels.attach.cameraDenied;
183
+ case 'failed':
184
+ return labels.attach.failed;
185
+ default:
186
+ return null;
187
+ }
188
+ };
189
+
190
+ const handlePick = async (source: AttachmentSource) => {
191
+ if (!mounted.current || !onPickAttachments || picking) return;
192
+ setPicking(true);
193
+ try {
194
+ const result = await onPickAttachments(source, drafts);
195
+ if (!mounted.current) return;
196
+ if (result.added.length > 0) {
197
+ setDrafts((current) => [...current, ...result.added].slice(0, MAX_ATTACHMENTS));
198
+ }
199
+ showNotice(problemLabel(result.problem));
200
+ } finally {
201
+ if (mounted.current) setPicking(false);
202
+ }
203
+ };
204
+
205
+ const menuOptions = attachmentSources.map((source) => ({
206
+ key: source,
207
+ label:
208
+ source === 'photo'
209
+ ? labels.attach.photoLibrary
210
+ : source === 'camera'
211
+ ? labels.attach.takePhoto
212
+ : labels.attach.file,
213
+ }));
214
+
215
+ const handleAttachPress = () => {
216
+ if (sending || picking) return;
217
+ if (drafts.length >= MAX_ATTACHMENTS) {
218
+ showNotice(labels.attach.tooMany);
219
+ return;
220
+ }
221
+ const only = attachmentSources.length === 1 ? attachmentSources[0] : undefined;
222
+ if (only) {
223
+ void handlePick(only);
224
+ return;
225
+ }
226
+ const native = showNativeAttachmentMenu(menuOptions, labels.attach.cancel, (source) => {
227
+ if (!mounted.current) return;
228
+ // Presenting a picker while the action sheet is still animating away
229
+ // fails silently on iOS; let it finish first.
230
+ pickerTimer.current = setTimeout(() => void handlePick(source), 350);
231
+ });
232
+ if (!native) setMenuOpen(true);
233
+ };
234
+
235
+ const handleMenuSelect = (source: AttachmentSource) => {
236
+ setMenuOpen(false);
237
+ void handlePick(source);
238
+ };
126
239
 
127
240
  const handleSend = () => {
128
241
  const trimmed = input.trim();
129
- if (!trimmed) return;
242
+ if (!canSend) return;
130
243
  setInput('');
131
244
  if (typingStopTimer.current) clearTimeout(typingStopTimer.current);
132
245
  onTyping(false);
133
- onSendMessage(trimmed);
134
246
  scrollToBottom();
247
+
248
+ if (drafts.length === 0) {
249
+ // The failed bubble already surfaces a failure to the visitor.
250
+ onSendMessage(trimmed).catch(() => {});
251
+ return;
252
+ }
253
+
254
+ const sent = drafts;
255
+ setSending(true);
256
+ showNotice(null);
257
+ onSendMessage(
258
+ trimmed,
259
+ sent.map((draft) => ({
260
+ name: draft.name,
261
+ url: draft.dataUrl,
262
+ contentType: draft.contentType,
263
+ size: draft.size,
264
+ previewUrl: draft.previewUri,
265
+ }))
266
+ )
267
+ .then(() => {
268
+ if (!mounted.current) return;
269
+ const sentIds = new Set(sent.map((draft) => draft.id));
270
+ setDrafts((current) => current.filter((draft) => !sentIds.has(draft.id)));
271
+ })
272
+ .catch((error: unknown) => {
273
+ // The visitor has moved to another thread: nothing to restore there.
274
+ if (!mounted.current) return;
275
+ // Chips stay for a retry; put the text back too unless they've
276
+ // already started typing something else.
277
+ setInput((current) => current || trimmed);
278
+ const status = (error as { status?: unknown } | null)?.status;
279
+ showNotice(status === 413 ? labels.attach.tooLargeTogether : labels.failedToSend);
280
+ })
281
+ .finally(() => {
282
+ if (mounted.current) setSending(false);
283
+ });
135
284
  };
136
285
 
137
286
  const handleChangeText = (text: string) => {
@@ -156,7 +305,7 @@ export function ChatView({
156
305
  phone: stringPayload(action.payload.phone),
157
306
  });
158
307
  }
159
- onSendMessage(formatAgentUiActionMessage(action), undefined, action);
308
+ onSendMessage(formatAgentUiActionMessage(action), undefined, action).catch(() => {});
160
309
  scrollToBottom();
161
310
  },
162
311
  [onIdentifyVisitor, onSendMessage, scrollToBottom]
@@ -165,6 +314,17 @@ export function ChatView({
165
314
  const headerTextColor = contrastingTextColor(color);
166
315
  const onDarkHeader = headerTextColor === palette.white;
167
316
  const isEmptyThread = !loading && messages.length === 0;
317
+ const agentIsActive = agentOnline ?? isOnline;
318
+ // "Waiting for a teammate" until someone replies: no teammate in the header
319
+ // and the visitor spoke last (bot replies before that still count as waiting).
320
+ const lastChatMessage = [...messages]
321
+ .reverse()
322
+ .find((message) => (message.messageType ?? 'message') === 'message' && !message.deletedAt);
323
+ const awaitingTeammate = !agentName && !agentTyping && lastChatMessage?.direction === 'inbound';
324
+ // "Sent" / "Seen" goes under the visitor's latest message only.
325
+ const lastVisitorMessageId = [...messages]
326
+ .reverse()
327
+ .find((message) => message.direction === 'inbound' && (message.messageType ?? 'message') === 'message')?.id;
168
328
  // Two stops off the brand color: flat fill looked like an unstyled block.
169
329
  // A non-hex brand color falls through mixHex unchanged and renders solid.
170
330
  const headerGradient = [color, mixHex(color, '#000000', 0.16)];
@@ -190,28 +350,57 @@ export function ChatView({
190
350
  <ChevronIcon size={18} direction={backDirection()} color={headerTextColor} />
191
351
  </Pressable>
192
352
 
193
- <View style={styles.headerIdentity}>
194
- <View style={styles.avatarWrapper}>
195
- <Avatar
196
- avatar={agentAvatar}
197
- name={agentName}
198
- size={36}
199
- translucent={onDarkHeader}
353
+ {agentName ? (
354
+ // A teammate joined: their photo, name and own presence.
355
+ <View style={styles.headerIdentity}>
356
+ <View style={styles.avatarWrapper}>
357
+ <Avatar
358
+ avatar={agentAvatar}
359
+ name={agentName}
360
+ size={36}
361
+ translucent={onDarkHeader}
362
+ color={palette.ink}
363
+ />
364
+ {agentIsActive ? <View style={styles.onlineDot} /> : null}
365
+ </View>
366
+ <View style={styles.headerText}>
367
+ <Text style={[styles.headerName, { color: headerTextColor }]} numberOfLines={1}>
368
+ {agentName}
369
+ </Text>
370
+ <Text
371
+ style={[styles.headerStatus, { color: withAlpha(headerTextColor, 0.75) }]}
372
+ numberOfLines={1}>
373
+ {agentIsActive ? labels.statusOnline : labels.statusAway}
374
+ </Text>
375
+ </View>
376
+ </View>
377
+ ) : (
378
+ // No teammate yet, or the conversation closed: the team, Intercom-style.
379
+ <View style={styles.headerIdentity}>
380
+ <TeamAvatars
381
+ avatars={teamAvatars}
382
+ size={30}
200
383
  color={palette.ink}
384
+ ringColor={color}
385
+ translucent={onDarkHeader}
201
386
  />
202
- {isOnline ? <View style={styles.onlineDot} /> : null}
203
- </View>
204
- <View style={styles.headerText}>
205
- <Text style={[styles.headerName, { color: headerTextColor }]} numberOfLines={1}>
206
- {agentName || labels.team}
207
- </Text>
208
- <Text
209
- style={[styles.headerStatus, { color: withAlpha(headerTextColor, 0.75) }]}
210
- numberOfLines={1}>
211
- {isOnline ? labels.statusOnline : labels.statusAway}
212
- </Text>
387
+ <View style={styles.headerText}>
388
+ <Text style={[styles.headerName, { color: headerTextColor }]} numberOfLines={1}>
389
+ {teamName || labels.team}
390
+ </Text>
391
+ {replyTimeLabel ? (
392
+ <View style={styles.headerReplyTime}>
393
+ <ClockIcon size={12} color={withAlpha(headerTextColor, 0.75)} />
394
+ <Text
395
+ style={[styles.headerStatus, styles.headerReplyTimeText, { color: withAlpha(headerTextColor, 0.75) }]}
396
+ numberOfLines={1}>
397
+ {replyTimeLabel}
398
+ </Text>
399
+ </View>
400
+ ) : null}
401
+ </View>
213
402
  </View>
214
- </View>
403
+ )}
215
404
 
216
405
  <Pressable
217
406
  onPress={onClose}
@@ -292,15 +481,17 @@ export function ChatView({
292
481
  message={message}
293
482
  color={color}
294
483
  labels={labels}
484
+ onOpenImage={setViewerImage}
295
485
  senderLabel={
296
486
  isAgentMessage && (startsRun || startsDay)
297
487
  ? message.senderName || agentName || labels.team
298
488
  : null
299
489
  }
300
490
  timeLabel={formatClockTime(message.createdAt, locale)}
491
+ receiptLabel={message.id === lastVisitorMessageId ? receiptLabel(message, labels) : undefined}
301
492
  onSubmitProcedureForm={
302
493
  shouldShowProcedureForm(messages, message)
303
- ? (body) => onSendMessage(body)
494
+ ? (body) => void onSendMessage(body).catch(() => {})
304
495
  : undefined
305
496
  }
306
497
  onAgentUiAction={
@@ -312,6 +503,13 @@ export function ChatView({
312
503
  );
313
504
  })}
314
505
 
506
+ {awaitingTeammate ? (
507
+ <View style={styles.waitingRow}>
508
+ <TeamAvatars avatars={teamAvatars} size={20} color={color} />
509
+ <Text style={styles.waitingText}>{labels.waitingForTeammate}</Text>
510
+ </View>
511
+ ) : null}
512
+
315
513
  {agentTyping ? (
316
514
  <View style={styles.typingRow}>
317
515
  <View style={styles.typingBubble}>
@@ -331,12 +529,48 @@ export function ChatView({
331
529
  ) : null}
332
530
 
333
531
  <View style={styles.composerWrapper}>
532
+ {notice ? (
533
+ <Text
534
+ style={styles.composerNotice}
535
+ accessibilityRole="alert"
536
+ accessibilityLiveRegion="polite">
537
+ {notice}
538
+ </Text>
539
+ ) : null}
540
+ {drafts.length > 0 ? (
541
+ <AttachmentChips
542
+ attachments={drafts}
543
+ disabled={sending}
544
+ removeLabel={labels.attach.remove}
545
+ onRemove={(id) => {
546
+ setDrafts((current) => current.filter((draft) => draft.id !== id));
547
+ showNotice(null);
548
+ }}
549
+ />
550
+ ) : null}
334
551
  <View
335
552
  style={[
336
553
  styles.composer,
554
+ showAttach && styles.composerWithAttach,
337
555
  composerFocused && styles.composerFocused,
338
556
  composerFocused && { borderColor: withAlpha(color, 0.55) },
339
557
  ]}>
558
+ {showAttach ? (
559
+ <Pressable
560
+ onPress={handleAttachPress}
561
+ disabled={sending || picking}
562
+ accessibilityRole="button"
563
+ accessibilityLabel={labels.attach.button}
564
+ accessibilityState={{ disabled: sending || picking }}
565
+ // 36pt circle + 4pt on every side = a 44pt target.
566
+ hitSlop={4}
567
+ style={({ pressed }) => [
568
+ styles.attachButton,
569
+ (pressed || sending || picking) && globalStyles.pressed,
570
+ ]}>
571
+ <PaperclipIcon size={20} color={palette.textMuted} />
572
+ </Pressable>
573
+ ) : null}
340
574
  <TextInput
341
575
  value={input}
342
576
  onChangeText={handleChangeText}
@@ -366,6 +600,25 @@ export function ChatView({
366
600
  </Pressable>
367
601
  </View>
368
602
  </View>
603
+
604
+ {showAttach && Platform.OS !== 'ios' ? (
605
+ <AttachmentMenu
606
+ visible={menuOpen}
607
+ options={menuOptions}
608
+ cancelLabel={labels.attach.cancel}
609
+ onSelect={handleMenuSelect}
610
+ onClose={() => setMenuOpen(false)}
611
+ />
612
+ ) : null}
613
+ <ImageViewer
614
+ image={viewerImage}
615
+ labels={labels.viewer}
616
+ onClose={() => setViewerImage(null)}
617
+ onOpenExternally={(url) => {
618
+ setViewerImage(null);
619
+ void openExternalUrl(url);
620
+ }}
621
+ />
369
622
  </View>
370
623
  );
371
624
  }
@@ -376,17 +629,38 @@ function MessageBubble({
376
629
  labels,
377
630
  senderLabel,
378
631
  timeLabel,
632
+ receiptLabel,
379
633
  onSubmitProcedureForm,
380
634
  onAgentUiAction,
635
+ onOpenImage,
381
636
  }: {
382
637
  message: Message;
383
638
  color: string;
384
639
  labels: ChatLabels;
385
640
  senderLabel?: string | null;
386
641
  timeLabel: string;
642
+ receiptLabel?: string;
387
643
  onSubmitProcedureForm?: (body: string) => void;
388
644
  onAgentUiAction?: (action: AgentUiAction) => void | Promise<void>;
645
+ onOpenImage: (image: { url: string; name: string }) => void;
389
646
  }) {
647
+ const joinedName = message.metadata?.event === 'agent_joined' ? message.metadata?.agentName : null;
648
+ if (message.messageType === 'system' && typeof joinedName === 'string' && joinedName) {
649
+ return (
650
+ <View style={[styles.systemRow, styles.joinedRow]}>
651
+ <Avatar
652
+ avatar={typeof message.metadata?.agentAvatar === 'string' ? message.metadata.agentAvatar : null}
653
+ name={joinedName}
654
+ size={20}
655
+ color={palette.ink}
656
+ />
657
+ <Text style={styles.systemText}>
658
+ <Text style={styles.joinedName}>{joinedName}</Text> {labels.joinedConversation}
659
+ </Text>
660
+ </View>
661
+ );
662
+ }
663
+
390
664
  if (message.messageType === 'system') {
391
665
  const text = message.bodyPlain || stripHtml(message.body || '') || labels.conversationUpdated;
392
666
  return (
@@ -397,6 +671,17 @@ function MessageBubble({
397
671
  }
398
672
 
399
673
  const isVisitor = message.direction === 'inbound';
674
+
675
+ if (message.deletedAt) {
676
+ return (
677
+ <View style={[styles.messageRow, isVisitor ? styles.messageRowRight : styles.messageRowLeft]}>
678
+ <View style={styles.deletedBubble}>
679
+ <Text style={styles.deletedText}>{labels.messageDeleted}</Text>
680
+ </View>
681
+ </View>
682
+ );
683
+ }
684
+
400
685
  const rtl = isRTL();
401
686
  const messageText = message.bodyPlain || stripHtml(message.body || '');
402
687
  const hasBody = !!messageText;
@@ -429,6 +714,7 @@ function MessageBubble({
429
714
  key={attachment.id}
430
715
  attachment={attachment}
431
716
  isVisitor={isVisitor}
717
+ onOpenImage={onOpenImage}
432
718
  />
433
719
  ))}
434
720
  </View>
@@ -455,6 +741,8 @@ function MessageBubble({
455
741
  ) : null}
456
742
  </View>
457
743
 
744
+ {receiptLabel ? <Text style={styles.receiptText}>{receiptLabel}</Text> : null}
745
+
458
746
  {agentUiBlocks.length > 0 && onAgentUiAction ? (
459
747
  <AgentUiBlocks
460
748
  blocks={agentUiBlocks}
@@ -476,21 +764,39 @@ function MessageBubble({
476
764
  );
477
765
  }
478
766
 
767
+ /** Sent once the server has it, Seen once a teammate opened the conversation. */
768
+ function receiptLabel(message: Message, labels: ChatLabels): string | undefined {
769
+ if (message.deletedAt || message.status === 'pending' || message.status === 'failed') return undefined;
770
+ return message.status === 'read' ? labels.messageSeen : labels.messageSent;
771
+ }
772
+
479
773
  function AttachmentPreview({
480
774
  attachment,
481
775
  isVisitor,
776
+ onOpenImage,
482
777
  }: {
483
778
  attachment: WidgetAttachment;
484
779
  isVisitor: boolean;
780
+ onOpenImage: (image: { url: string; name: string }) => void;
485
781
  }) {
486
782
  // `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);
783
+ // An unsent attachment shows its local file (or data URL): it renders, but
784
+ // there is nothing to open until the server copy arrives.
785
+ const [imageFailed, setImageFailed] = useState(false);
786
+ const isImage = isImageAttachment(attachment.contentType) && !!attachment.url && !imageFailed;
787
+ const openable = isHttpUrl(attachment.url);
788
+ // Images open in the in-app viewer; other files (PDF, sheets) need the OS.
789
+ const open = !openable
790
+ ? undefined
791
+ : isImage
792
+ ? () => onOpenImage({ url: attachment.url, name: attachment.name })
793
+ : () => void openExternalUrl(attachment.url);
489
794
 
490
795
  if (isImage) {
491
796
  return (
492
797
  <Pressable
493
798
  onPress={open}
799
+ disabled={!openable}
494
800
  accessibilityRole="imagebutton"
495
801
  accessibilityLabel={attachment.name}>
496
802
  <Image
@@ -498,6 +804,9 @@ function AttachmentPreview({
498
804
  style={styles.attachmentImage}
499
805
  resizeMode="cover"
500
806
  accessibilityIgnoresInvertColors
807
+ // An image that can't load (expired link, deleted file) falls back to
808
+ // the file row below instead of an empty box.
809
+ onError={() => setImageFailed(true)}
501
810
  />
502
811
  </Pressable>
503
812
  );
@@ -506,6 +815,7 @@ function AttachmentPreview({
506
815
  return (
507
816
  <Pressable
508
817
  onPress={open}
818
+ disabled={!openable}
509
819
  accessibilityRole="button"
510
820
  style={[
511
821
  styles.attachmentFile,
@@ -1,7 +1,15 @@
1
+ import type { TeamAvatar } from '../../../types';
1
2
  /** Props and label bundle for the chat thread. */
2
3
 
3
- import type { AgentUiAction, Message, WidgetAttachment } from '../../../types';
4
+ import type { DraftAttachment, PickResult } from '../../../attachments/pickers';
5
+ import type {
6
+ AgentUiAction,
7
+ AttachmentSource,
8
+ Message,
9
+ OutgoingAttachment,
10
+ } from '../../../types';
4
11
  import type { AgentUiBlockLabels } from '../../components/AgentUiBlocks';
12
+ import type { ImageViewerLabels } from '../../components/ImageViewer';
5
13
 
6
14
  export interface ChatLabels {
7
15
  back: string;
@@ -18,9 +26,31 @@ export interface ChatLabels {
18
26
  today: string;
19
27
  yesterday: string;
20
28
  conversationUpdated: string;
29
+ messageDeleted: string;
30
+ messageSent: string;
31
+ messageSeen: string;
32
+ waitingForTeammate: string;
33
+ joinedConversation: string;
21
34
  procedureSend: string;
22
35
  procedureSent: string;
23
36
  blocks: AgentUiBlockLabels;
37
+ attach: AttachLabels;
38
+ viewer: ImageViewerLabels;
39
+ }
40
+
41
+ /** Composer attachment copy. Limits are already filled in; `remove` keeps `{name}`. */
42
+ export interface AttachLabels {
43
+ button: string;
44
+ photoLibrary: string;
45
+ takePhoto: string;
46
+ file: string;
47
+ cancel: string;
48
+ remove: string;
49
+ tooLarge: string;
50
+ tooMany: string;
51
+ tooLargeTogether: string;
52
+ failed: string;
53
+ cameraDenied: string;
24
54
  }
25
55
 
26
56
  export interface ChatViewProps {
@@ -32,12 +62,31 @@ export interface ChatViewProps {
32
62
  /** Whoever is handling this thread, once the backend assigns someone. */
33
63
  agentName?: string | null;
34
64
  agentAvatar?: string | null;
65
+ /** The assigned teammate's own presence; falls back to `isOnline` when absent. */
66
+ agentOnline?: boolean | null;
35
67
  isOnline: boolean;
68
+ /** Header title with no teammate yet (or after close); falls back to the Team label. */
69
+ teamName?: string | null;
70
+ teamAvatars?: TeamAvatar[];
71
+ /** e.g. "We usually reply within a few minutes", under the team name. */
72
+ replyTimeLabel?: string;
36
73
  prefillText?: string;
37
74
  offlineMessage?: string;
38
75
  locale: string;
39
76
  labels: ChatLabels;
40
- onSendMessage: (body: string, attachments?: WidgetAttachment[], action?: AgentUiAction) => void;
77
+ /**
78
+ * Menu options for the attach button, in order. Empty (or absent) hides the
79
+ * button — attachments are off, or no picker is installed.
80
+ */
81
+ attachmentSources?: AttachmentSource[];
82
+ /** Opens a picker and returns what fits beside `current`. Never rejects. */
83
+ onPickAttachments?: (source: AttachmentSource, current: DraftAttachment[]) => Promise<PickResult>;
84
+ /** Rejects when the send fails, so the composer can keep its attachments. */
85
+ onSendMessage: (
86
+ body: string,
87
+ attachments?: OutgoingAttachment[],
88
+ action?: AgentUiAction
89
+ ) => Promise<void>;
41
90
  onIdentifyVisitor?: (data: { email?: string; name?: string; phone?: string }) => Promise<void>;
42
91
  onLoadMore: () => void;
43
92
  onTyping: (isTyping: boolean) => void;