@repzo/widget-react-native 1.3.1 → 1.4.0

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 (224) hide show
  1. package/CHANGELOG.md +137 -5
  2. package/README.md +64 -12
  3. package/lib/commonjs/api/client.js +96 -23
  4. package/lib/commonjs/api/client.js.map +1 -1
  5. package/lib/commonjs/attachments/file-size.js +52 -0
  6. package/lib/commonjs/attachments/file-size.js.map +1 -0
  7. package/lib/commonjs/attachments/local-previews.js +40 -0
  8. package/lib/commonjs/attachments/local-previews.js.map +1 -0
  9. package/lib/commonjs/attachments/pickers.js +20 -29
  10. package/lib/commonjs/attachments/pickers.js.map +1 -1
  11. package/lib/commonjs/i18n.js +22 -3
  12. package/lib/commonjs/i18n.js.map +1 -1
  13. package/lib/commonjs/locales.js +12 -3
  14. package/lib/commonjs/locales.js.map +1 -1
  15. package/lib/commonjs/state/widget-store.js +277 -30
  16. package/lib/commonjs/state/widget-store.js.map +1 -1
  17. package/lib/commonjs/storage.js +3 -1
  18. package/lib/commonjs/storage.js.map +1 -1
  19. package/lib/commonjs/types.js.map +1 -1
  20. package/lib/commonjs/ui/WidgetProvider.js +33 -13
  21. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  22. package/lib/commonjs/ui/components/AttachmentChips.js +109 -40
  23. package/lib/commonjs/ui/components/AttachmentChips.js.map +1 -1
  24. package/lib/commonjs/ui/components/ConversationRow.js +65 -10
  25. package/lib/commonjs/ui/components/ConversationRow.js.map +1 -1
  26. package/lib/commonjs/ui/components/ImageViewer.js +229 -24
  27. package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
  28. package/lib/commonjs/ui/components/RatingCard.js +5 -2
  29. package/lib/commonjs/ui/components/RatingCard.js.map +1 -1
  30. package/lib/commonjs/ui/components/ZoomableImage.js +58 -10
  31. package/lib/commonjs/ui/components/ZoomableImage.js.map +1 -1
  32. package/lib/commonjs/ui/hooks/use-retained-state.js +39 -0
  33. package/lib/commonjs/ui/hooks/use-retained-state.js.map +1 -0
  34. package/lib/commonjs/ui/screens/chat/ChatView.js +637 -164
  35. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  36. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +138 -1
  37. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  38. package/lib/commonjs/ui/screens/conversations/ConversationsList.js +1 -0
  39. package/lib/commonjs/ui/screens/conversations/ConversationsList.js.map +1 -1
  40. package/lib/commonjs/ui/screens/home/HomeView.js +38 -55
  41. package/lib/commonjs/ui/screens/home/HomeView.js.map +1 -1
  42. package/lib/commonjs/ui/screens/home/HomeView.styles.js +36 -45
  43. package/lib/commonjs/ui/screens/home/HomeView.styles.js.map +1 -1
  44. package/lib/commonjs/ui/utils/html.js +13 -0
  45. package/lib/commonjs/ui/utils/html.js.map +1 -1
  46. package/lib/commonjs/ui/utils/image-load-queue.js +114 -0
  47. package/lib/commonjs/ui/utils/image-load-queue.js.map +1 -0
  48. package/lib/commonjs/ui/utils/inline-attachments.js +88 -0
  49. package/lib/commonjs/ui/utils/inline-attachments.js.map +1 -0
  50. package/lib/module/api/client.js +96 -23
  51. package/lib/module/api/client.js.map +1 -1
  52. package/lib/module/attachments/file-size.js +47 -0
  53. package/lib/module/attachments/file-size.js.map +1 -0
  54. package/lib/module/attachments/local-previews.js +33 -0
  55. package/lib/module/attachments/local-previews.js.map +1 -0
  56. package/lib/module/attachments/pickers.js +15 -25
  57. package/lib/module/attachments/pickers.js.map +1 -1
  58. package/lib/module/i18n.js +22 -3
  59. package/lib/module/i18n.js.map +1 -1
  60. package/lib/module/locales.js +12 -3
  61. package/lib/module/locales.js.map +1 -1
  62. package/lib/module/state/widget-store.js +279 -32
  63. package/lib/module/state/widget-store.js.map +1 -1
  64. package/lib/module/storage.js +2 -0
  65. package/lib/module/storage.js.map +1 -1
  66. package/lib/module/types.js.map +1 -1
  67. package/lib/module/ui/WidgetProvider.js +33 -13
  68. package/lib/module/ui/WidgetProvider.js.map +1 -1
  69. package/lib/module/ui/components/AttachmentChips.js +111 -42
  70. package/lib/module/ui/components/AttachmentChips.js.map +1 -1
  71. package/lib/module/ui/components/ConversationRow.js +65 -10
  72. package/lib/module/ui/components/ConversationRow.js.map +1 -1
  73. package/lib/module/ui/components/ImageViewer.js +231 -26
  74. package/lib/module/ui/components/ImageViewer.js.map +1 -1
  75. package/lib/module/ui/components/RatingCard.js +5 -2
  76. package/lib/module/ui/components/RatingCard.js.map +1 -1
  77. package/lib/module/ui/components/ZoomableImage.js +58 -10
  78. package/lib/module/ui/components/ZoomableImage.js.map +1 -1
  79. package/lib/module/ui/hooks/use-retained-state.js +34 -0
  80. package/lib/module/ui/hooks/use-retained-state.js.map +1 -0
  81. package/lib/module/ui/screens/chat/ChatView.js +641 -168
  82. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  83. package/lib/module/ui/screens/chat/ChatView.styles.js +140 -2
  84. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  85. package/lib/module/ui/screens/conversations/ConversationsList.js +1 -0
  86. package/lib/module/ui/screens/conversations/ConversationsList.js.map +1 -1
  87. package/lib/module/ui/screens/home/HomeView.js +40 -57
  88. package/lib/module/ui/screens/home/HomeView.js.map +1 -1
  89. package/lib/module/ui/screens/home/HomeView.styles.js +36 -45
  90. package/lib/module/ui/screens/home/HomeView.styles.js.map +1 -1
  91. package/lib/module/ui/utils/html.js +12 -0
  92. package/lib/module/ui/utils/html.js.map +1 -1
  93. package/lib/module/ui/utils/image-load-queue.js +109 -0
  94. package/lib/module/ui/utils/image-load-queue.js.map +1 -0
  95. package/lib/module/ui/utils/inline-attachments.js +85 -0
  96. package/lib/module/ui/utils/inline-attachments.js.map +1 -0
  97. package/lib/typescript/commonjs/api/client.d.ts +12 -4
  98. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/attachments/file-size.d.ts +15 -0
  100. package/lib/typescript/commonjs/attachments/file-size.d.ts.map +1 -0
  101. package/lib/typescript/commonjs/attachments/local-previews.d.ts +14 -0
  102. package/lib/typescript/commonjs/attachments/local-previews.d.ts.map +1 -0
  103. package/lib/typescript/commonjs/attachments/pickers.d.ts +15 -2
  104. package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
  105. package/lib/typescript/commonjs/i18n.d.ts +12 -3
  106. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  107. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  108. package/lib/typescript/commonjs/state/widget-store.d.ts +40 -1
  109. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  110. package/lib/typescript/commonjs/storage.d.ts +2 -0
  111. package/lib/typescript/commonjs/storage.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/types.d.ts +41 -0
  113. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  114. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  115. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts +13 -3
  116. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts.map +1 -1
  117. package/lib/typescript/commonjs/ui/components/ConversationRow.d.ts +15 -1
  118. package/lib/typescript/commonjs/ui/components/ConversationRow.d.ts.map +1 -1
  119. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +26 -6
  120. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
  121. package/lib/typescript/commonjs/ui/components/RatingCard.d.ts.map +1 -1
  122. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts +10 -1
  123. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts.map +1 -1
  124. package/lib/typescript/commonjs/ui/hooks/use-retained-state.d.ts +11 -0
  125. package/lib/typescript/commonjs/ui/hooks/use-retained-state.d.ts.map +1 -0
  126. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +4 -4
  127. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  128. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +144 -0
  129. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  130. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +26 -1
  131. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  132. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.d.ts.map +1 -1
  133. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.types.d.ts +2 -0
  134. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.types.d.ts.map +1 -1
  135. package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts +7 -5
  136. package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts.map +1 -1
  137. package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts +30 -35
  138. package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts.map +1 -1
  139. package/lib/typescript/commonjs/ui/screens/home/HomeView.types.d.ts +5 -6
  140. package/lib/typescript/commonjs/ui/screens/home/HomeView.types.d.ts.map +1 -1
  141. package/lib/typescript/commonjs/ui/utils/html.d.ts +5 -0
  142. package/lib/typescript/commonjs/ui/utils/html.d.ts.map +1 -1
  143. package/lib/typescript/commonjs/ui/utils/image-load-queue.d.ts +44 -0
  144. package/lib/typescript/commonjs/ui/utils/image-load-queue.d.ts.map +1 -0
  145. package/lib/typescript/commonjs/ui/utils/inline-attachments.d.ts +29 -0
  146. package/lib/typescript/commonjs/ui/utils/inline-attachments.d.ts.map +1 -0
  147. package/lib/typescript/module/api/client.d.ts +12 -4
  148. package/lib/typescript/module/api/client.d.ts.map +1 -1
  149. package/lib/typescript/module/attachments/file-size.d.ts +15 -0
  150. package/lib/typescript/module/attachments/file-size.d.ts.map +1 -0
  151. package/lib/typescript/module/attachments/local-previews.d.ts +14 -0
  152. package/lib/typescript/module/attachments/local-previews.d.ts.map +1 -0
  153. package/lib/typescript/module/attachments/pickers.d.ts +15 -2
  154. package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
  155. package/lib/typescript/module/i18n.d.ts +12 -3
  156. package/lib/typescript/module/i18n.d.ts.map +1 -1
  157. package/lib/typescript/module/locales.d.ts.map +1 -1
  158. package/lib/typescript/module/state/widget-store.d.ts +40 -1
  159. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  160. package/lib/typescript/module/storage.d.ts +2 -0
  161. package/lib/typescript/module/storage.d.ts.map +1 -1
  162. package/lib/typescript/module/types.d.ts +41 -0
  163. package/lib/typescript/module/types.d.ts.map +1 -1
  164. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  165. package/lib/typescript/module/ui/components/AttachmentChips.d.ts +13 -3
  166. package/lib/typescript/module/ui/components/AttachmentChips.d.ts.map +1 -1
  167. package/lib/typescript/module/ui/components/ConversationRow.d.ts +15 -1
  168. package/lib/typescript/module/ui/components/ConversationRow.d.ts.map +1 -1
  169. package/lib/typescript/module/ui/components/ImageViewer.d.ts +26 -6
  170. package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
  171. package/lib/typescript/module/ui/components/RatingCard.d.ts.map +1 -1
  172. package/lib/typescript/module/ui/components/ZoomableImage.d.ts +10 -1
  173. package/lib/typescript/module/ui/components/ZoomableImage.d.ts.map +1 -1
  174. package/lib/typescript/module/ui/hooks/use-retained-state.d.ts +11 -0
  175. package/lib/typescript/module/ui/hooks/use-retained-state.d.ts.map +1 -0
  176. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +4 -4
  177. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  178. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +144 -0
  179. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  180. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +26 -1
  181. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  182. package/lib/typescript/module/ui/screens/conversations/ConversationsList.d.ts.map +1 -1
  183. package/lib/typescript/module/ui/screens/conversations/ConversationsList.types.d.ts +2 -0
  184. package/lib/typescript/module/ui/screens/conversations/ConversationsList.types.d.ts.map +1 -1
  185. package/lib/typescript/module/ui/screens/home/HomeView.d.ts +7 -5
  186. package/lib/typescript/module/ui/screens/home/HomeView.d.ts.map +1 -1
  187. package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts +30 -35
  188. package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts.map +1 -1
  189. package/lib/typescript/module/ui/screens/home/HomeView.types.d.ts +5 -6
  190. package/lib/typescript/module/ui/screens/home/HomeView.types.d.ts.map +1 -1
  191. package/lib/typescript/module/ui/utils/html.d.ts +5 -0
  192. package/lib/typescript/module/ui/utils/html.d.ts.map +1 -1
  193. package/lib/typescript/module/ui/utils/image-load-queue.d.ts +44 -0
  194. package/lib/typescript/module/ui/utils/image-load-queue.d.ts.map +1 -0
  195. package/lib/typescript/module/ui/utils/inline-attachments.d.ts +29 -0
  196. package/lib/typescript/module/ui/utils/inline-attachments.d.ts.map +1 -0
  197. package/package.json +1 -1
  198. package/src/api/client.ts +116 -34
  199. package/src/attachments/file-size.ts +42 -0
  200. package/src/attachments/local-previews.ts +32 -0
  201. package/src/attachments/pickers.ts +23 -25
  202. package/src/i18n.ts +22 -3
  203. package/src/locales.ts +12 -3
  204. package/src/state/widget-store.ts +322 -50
  205. package/src/storage.ts +2 -0
  206. package/src/types.ts +41 -0
  207. package/src/ui/WidgetProvider.tsx +35 -8
  208. package/src/ui/components/AttachmentChips.tsx +100 -29
  209. package/src/ui/components/ConversationRow.tsx +90 -9
  210. package/src/ui/components/ImageViewer.tsx +242 -24
  211. package/src/ui/components/RatingCard.tsx +5 -2
  212. package/src/ui/components/ZoomableImage.tsx +58 -5
  213. package/src/ui/hooks/use-retained-state.ts +35 -0
  214. package/src/ui/screens/chat/ChatView.styles.ts +139 -1
  215. package/src/ui/screens/chat/ChatView.tsx +721 -197
  216. package/src/ui/screens/chat/ChatView.types.ts +26 -1
  217. package/src/ui/screens/conversations/ConversationsList.tsx +1 -0
  218. package/src/ui/screens/conversations/ConversationsList.types.ts +2 -0
  219. package/src/ui/screens/home/HomeView.styles.ts +36 -45
  220. package/src/ui/screens/home/HomeView.tsx +54 -70
  221. package/src/ui/screens/home/HomeView.types.ts +5 -6
  222. package/src/ui/utils/html.ts +26 -0
  223. package/src/ui/utils/image-load-queue.ts +108 -0
  224. package/src/ui/utils/inline-attachments.ts +71 -0
@@ -2,16 +2,28 @@
2
2
  * Chat thread — message list, typing indicator and composer.
3
3
  * Port of `client/src/widget/components/ChatView.tsx`.
4
4
  *
5
- * Mobile differences: older messages load through an explicit "Load earlier
6
- * messages" control (a scroll-anchored infinite list fights the keyboard), and
7
- * the attach button only shows when the host app has a picker to open (see
5
+ * Mobile differences: the thread is an inverted list, so it opens on the
6
+ * newest message and older pages load as the visitor scrolls up, and the
7
+ * attach button only shows when the host app has a picker to open (see
8
8
  * `attachments/pickers.ts`).
9
9
  */
10
10
 
11
- import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
12
11
  import {
12
+ createContext,
13
+ memo,
14
+ useCallback,
15
+ useContext,
16
+ useEffect,
17
+ useMemo,
18
+ useRef,
19
+ useState,
20
+ useSyncExternalStore,
21
+ } from 'react';
22
+ import {
23
+ ActivityIndicator,
13
24
  Animated,
14
25
  Easing,
26
+ FlatList,
15
27
  Image,
16
28
  Platform,
17
29
  Pressable,
@@ -20,16 +32,25 @@ import {
20
32
  TextInput,
21
33
  View,
22
34
  } from 'react-native';
23
- import type { LayoutChangeEvent } from 'react-native';
35
+ import type {
36
+ ListRenderItem,
37
+ NativeScrollEvent,
38
+ NativeSyntheticEvent,
39
+ StyleProp,
40
+ ViewStyle,
41
+ ViewToken,
42
+ } from 'react-native';
24
43
 
25
44
  import {
26
45
  formatAgentUiActionMessage,
27
46
  getAgentUiBlocks,
28
47
  getProcedureForm,
29
48
  } from '../../../agent-ui-blocks';
49
+ import { forgetLocalPreview, localPreviewFor } from '../../../attachments/local-previews';
30
50
  import {
31
51
  MAX_ATTACHMENTS,
32
52
  isImageAttachment,
53
+ uploadsOnPick,
33
54
  type AttachmentProblem,
34
55
  type DraftAttachment,
35
56
  } from '../../../attachments/pickers';
@@ -45,7 +66,7 @@ import { AgentUiBlocks } from '../../components/AgentUiBlocks';
45
66
  import { AttachmentChips } from '../../components/AttachmentChips';
46
67
  import { AttachmentMenu, showNativeAttachmentMenu } from '../../components/AttachmentMenu';
47
68
  import { Avatar } from '../../components/Avatar';
48
- import { ImageViewer } from '../../components/ImageViewer';
69
+ import { ImageViewer, type ViewerGallery } from '../../components/ImageViewer';
49
70
  import { getRatingRequest, RatingCard } from '../../components/RatingCard';
50
71
  import { WidgetErrorBoundary } from '../../components/ErrorBoundary';
51
72
  import { GradientBackground } from '../../components/Gradient';
@@ -53,20 +74,85 @@ import { AlertIcon, ArrowUpIcon, ChatOutlineIcon, ChevronIcon, ClockIcon, PlusIc
53
74
  import { TeamAvatars } from '../../components/TeamAvatars';
54
75
  import { Markdown } from '../../components/Markdown';
55
76
  import { useReduceMotion } from '../../hooks/use-reduce-motion';
77
+ import { useRetainedState } from '../../hooks/use-retained-state';
56
78
  import { useTopInset } from '../../hooks/use-top-inset';
57
79
  import { globalStyles } from '../../styles/global';
58
80
  import { contrastingTextColor, mixHex, palette, spacing, withAlpha } from '../../styles/theme';
59
81
  import { canRetryMessage, formatClockTime, formatDayLabel, isSameDay } from '../../utils/conversations';
60
82
  import { stripHtml } from '../../utils/html';
83
+ import { ImageLoadQueue } from '../../utils/image-load-queue';
84
+ import { splitInlineAttachments } from '../../utils/inline-attachments';
61
85
  import { isHttpUrl, openExternalUrl } from '../../utils/safe-url';
62
86
  import { isPlayableAudio } from '../../../audio/voice';
63
87
  import { VoiceNote, type VoiceNoteLabels } from '../../components/VoiceNote';
64
- import { FileCard } from '../../components/FileCard';
88
+ import { FileCard, fileTypeLabel } from '../../components/FileCard';
65
89
  import { MessagesSkeleton } from '../../components/MessagesSkeleton';
66
90
  import { backDirection, isRTL } from '../../utils/rtl';
67
91
  import { styles } from './ChatView.styles';
68
92
  import type { ChatLabels, ChatViewProps } from './ChatView.types';
69
93
 
94
+ /** A message with what its place in the thread decides about it. */
95
+ interface ThreadRow {
96
+ message: Message;
97
+ /** First message of its day: a day separator goes above it. */
98
+ startsDay: boolean;
99
+ /** First of a teammate's run: their name goes above it. */
100
+ startsRun: boolean;
101
+ }
102
+
103
+ const rowKey = (row: ThreadRow) => row.message.id;
104
+
105
+ /** How far above the newest message the visitor scrolls before "jump to latest" shows. */
106
+ const JUMP_TO_LATEST_OFFSET = 240;
107
+
108
+ /**
109
+ * Workstation's refusal of a message that names an expired upload. Only this
110
+ * one puts the files up again: a deleted conversation or a rejected message
111
+ * would refuse the fresh uploads too.
112
+ */
113
+ const UPLOAD_EXPIRED_STATUS = 410;
114
+ /**
115
+ * An upload this close to expiring goes up again with the message instead of
116
+ * being named by its token, which could lapse before the message arrives.
117
+ */
118
+ const UPLOAD_EXPIRY_MARGIN_MS = 10 * 60 * 1000;
119
+
120
+ /**
121
+ * Photos above this take turns to download, two at a time; smaller ones load
122
+ * at once. Two 50 MB photos stay well inside Android's download pool (see
123
+ * `ImageLoadQueue`), and ordinary photos never wait.
124
+ */
125
+ const LARGE_IMAGE_BYTES = 10_000_000;
126
+ const LARGE_IMAGE_LOADS_AT_ONCE = 2;
127
+ /** A photo that loads quicker than this never shows its loader. */
128
+ const PHOTO_LOADER_DELAY_MS = 200;
129
+
130
+ /** A photo download that waits its turn; the phone's own copy never does. */
131
+ const isLargeDownload = (url: string, size: number | undefined) =>
132
+ isHttpUrl(url) && (size ?? 0) > LARGE_IMAGE_BYTES;
133
+
134
+ const ImageQueueContext = createContext<ImageLoadQueue | null>(null);
135
+ const subscribeNowhere = () => () => {};
136
+
137
+ /**
138
+ * Whether an image may load yet, and the callback that tells the queue it
139
+ * has (or failed). Images the queue doesn't handle load right away.
140
+ */
141
+ function useImageTurn(key: string, priority: number, queued: boolean): [boolean, () => void] {
142
+ const queue = useContext(ImageQueueContext);
143
+ const active = !!queue && queued;
144
+ useEffect(() => (active ? queue!.add(key, priority) : undefined), [active, queue, key, priority]);
145
+ const turn = useSyncExternalStore(queue ? queue.subscribe : subscribeNowhere, () =>
146
+ active ? queue!.canLoad(key) : true
147
+ );
148
+ const settled = useCallback(() => queue?.finish(key), [queue, key]);
149
+ return [turn, settled];
150
+ }
151
+
152
+ function ThreadGap() {
153
+ return <View style={styles.threadGap} />;
154
+ }
155
+
70
156
  export function ChatView({
71
157
  messages,
72
158
  loading,
@@ -86,11 +172,13 @@ export function ChatView({
86
172
  labels,
87
173
  attachmentSources = [],
88
174
  onPickAttachments,
175
+ onUploadAttachment,
89
176
  onSendMessage,
90
177
  onRetryMessage,
91
178
  onIdentifyVisitor,
92
179
  onRateConversation,
93
180
  defaultLocale,
181
+ photoAlbums = true,
94
182
  onLoadMore,
95
183
  onTyping,
96
184
  onBack,
@@ -100,31 +188,47 @@ export function ChatView({
100
188
  const [composerFocused, setComposerFocused] = useState(false);
101
189
  const [drafts, setDrafts] = useState<DraftAttachment[]>([]);
102
190
  const [menuOpen, setMenuOpen] = useState(false);
103
- const [viewerImage, setViewerImage] = useState<{ url: string; name: string } | null>(null);
191
+ const [viewerGallery, setViewerGallery] = useState<ViewerGallery | null>(null);
104
192
  const [picking, setPicking] = useState(false);
105
193
  // Only attachment sends hold the composer; text sends stay fire-and-forget.
106
194
  const [sending, setSending] = useState(false);
107
195
  const [notice, setNotice] = useState<string | null>(null);
108
196
  const reduceMotion = useReduceMotion();
109
- const scrollRef = useRef<ScrollView>(null);
197
+ const listRef = useRef<FlatList<ThreadRow>>(null);
198
+ // One per thread: the screen remounts for each conversation.
199
+ const [largeImageQueue] = useState(() => new ImageLoadQueue(LARGE_IMAGE_LOADS_AT_ONCE));
110
200
  const typingStopTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
111
201
  const noticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
112
202
  const pickerTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
113
203
  const mounted = useRef(false);
204
+ // Uploads in flight, by draft id. A removed chip cancels its own; a retry
205
+ // replaces it, and the replaced attempt's result is dropped.
206
+ const uploads = useRef(new Map<string, { cancel: () => void }>());
207
+ // Files already put up again after a refused send; never twice, so a
208
+ // refusal uploads can't fix costs one round, not one per tap on Send.
209
+ const reuploaded = useRef(new Set<string>());
210
+ // Set as the visitor sends: the list scrolls down once their bubble is in
211
+ // it. Scrolled up, scrolling first let the list hold its place as the new
212
+ // row arrived, which could cut the scroll short.
213
+ const followSend = useRef(false);
114
214
 
115
215
  useEffect(() => {
116
216
  if (prefillText) setInput(prefillText);
117
217
  }, [prefillText]);
118
218
 
119
219
  // The provider keys this screen by the store's thread generation. Pending
120
- // picks and sends belong to this mount, even before a draft gets a server ID.
220
+ // picks, uploads and sends belong to this mount, even before a draft gets a
221
+ // server ID.
121
222
  useEffect(() => {
122
223
  mounted.current = true;
224
+ const inFlight = uploads.current;
123
225
  return () => {
124
226
  mounted.current = false;
125
227
  if (typingStopTimer.current) clearTimeout(typingStopTimer.current);
126
228
  if (noticeTimer.current) clearTimeout(noticeTimer.current);
127
229
  if (pickerTimer.current) clearTimeout(pickerTimer.current);
230
+ inFlight.forEach((upload) => upload.cancel());
231
+ inFlight.clear();
128
232
  };
129
233
  }, []);
130
234
 
@@ -134,51 +238,57 @@ export function ChatView({
134
238
  if (message) noticeTimer.current = setTimeout(() => setNotice(null), 5000);
135
239
  }, []);
136
240
 
241
+ // The list is inverted, so offset 0 is the newest message: the thread opens
242
+ // there without scrolling, stays there as the keyboard rises or replies
243
+ // arrive, and older pages land above it without moving the reader.
137
244
  const scrollToBottom = useCallback((animated = true) => {
138
- scrollRef.current?.scrollToEnd({ animated });
245
+ listRef.current?.scrollToOffset({ offset: 0, animated });
139
246
  }, []);
140
247
 
141
- // Land on the newest message the moment the thread opens — notification
142
- // taps and conversation-list taps both mount this screen. The content-size
143
- // handler below covers later growth (loads, images, incoming messages).
144
- useEffect(() => {
145
- const frame = requestAnimationFrame(() => scrollToBottom(false));
146
- return () => cancelAnimationFrame(frame);
147
- }, [scrollToBottom]);
148
-
149
- // "Load earlier" PREPENDS history; skipping one auto-scroll keeps the reader
150
- // anchored where they were instead of yanking them back to the bottom.
151
- const skipNextAutoScroll = useRef(false);
248
+ // Fires as the visitor nears the oldest message; the row up there is the
249
+ // fallback when a page fails to load.
152
250
  const handleLoadEarlier = useCallback(() => {
153
- skipNextAutoScroll.current = true;
154
- onLoadMore();
155
- }, [onLoadMore]);
251
+ if (hasMoreMessages && !loading) onLoadMore();
252
+ }, [hasMoreMessages, loading, onLoadMore]);
156
253
 
157
- const handleContentSizeChange = useCallback(() => {
158
- if (skipNextAutoScroll.current) {
159
- skipNextAutoScroll.current = false;
160
- return;
161
- }
162
- scrollToBottom(false);
163
- }, [scrollToBottom]);
164
-
165
- // The keyboard shortens the thread instead of scrolling it, so whatever was
166
- // at the bottom — the newest message, an agent form — would slide in behind
167
- // the composer. Re-pin on every shrink, unanimated, so the list tracks the
168
- // keyboard as it rises rather than jumping once it lands.
169
- const viewportHeight = useRef(0);
170
- const handleViewportLayout = useCallback(
171
- (event: LayoutChangeEvent) => {
172
- const { height } = event.nativeEvent.layout;
173
- const shrank = viewportHeight.current - height > 1;
174
- viewportHeight.current = height;
175
- if (shrank) scrollToBottom(false);
176
- },
177
- [scrollToBottom]
254
+ const showAttach = attachmentSources.length > 0 && !!onPickAttachments;
255
+ // Send waits until every picked file is up, so the message goes out at once.
256
+ const uploadsPending = drafts.some(
257
+ (draft) => draft.uploadStatus === 'uploading' || draft.uploadStatus === 'failed'
258
+ );
259
+ const canSend = !sending && !uploadsPending && (input.trim().length > 0 || drafts.length > 0);
260
+
261
+ const updateDraft = useCallback(
262
+ (id: string, patch: Partial<DraftAttachment>) =>
263
+ setDrafts((current) =>
264
+ current.map((draft) => (draft.id === id ? { ...draft, ...patch } : draft))
265
+ ),
266
+ []
178
267
  );
179
268
 
180
- const showAttach = attachmentSources.length > 0 && !!onPickAttachments;
181
- const canSend = !sending && (input.trim().length > 0 || drafts.length > 0);
269
+ /** Puts a picked local file up while the visitor writes; inline files need nothing. */
270
+ const startUpload = (draft: DraftAttachment) => {
271
+ if (!onUploadAttachment || !draft.fileUri) return;
272
+ uploads.current.get(draft.id)?.cancel();
273
+ const controller = typeof AbortController === 'function' ? new AbortController() : null;
274
+ const attempt = { cancel: () => controller?.abort() };
275
+ uploads.current.set(draft.id, attempt);
276
+ updateDraft(draft.id, { uploadStatus: 'uploading', uploadToken: undefined, uploadExpiresAt: undefined });
277
+ onUploadAttachment(draft, controller?.signal).then(
278
+ ({ uploadToken, expiresAt }) => {
279
+ if (!mounted.current || uploads.current.get(draft.id) !== attempt) return;
280
+ uploads.current.delete(draft.id);
281
+ updateDraft(draft.id, { uploadStatus: 'uploaded', uploadToken, uploadExpiresAt: expiresAt });
282
+ },
283
+ (error: unknown) => {
284
+ if (!mounted.current || uploads.current.get(draft.id) !== attempt) return;
285
+ console.warn('[repzo-widget] attachment upload failed', error);
286
+ uploads.current.delete(draft.id);
287
+ updateDraft(draft.id, { uploadStatus: 'failed' });
288
+ showNotice(labels.attach.uploadFailedNotice);
289
+ }
290
+ );
291
+ };
182
292
 
183
293
  const problemLabel = (problem: AttachmentProblem | null): string | null => {
184
294
  switch (problem) {
@@ -205,6 +315,8 @@ export function ChatView({
205
315
  if (!mounted.current) return;
206
316
  if (result.added.length > 0) {
207
317
  setDrafts((current) => [...current, ...result.added].slice(0, MAX_ATTACHMENTS));
318
+ // Opted out, the files go up when the visitor taps Send instead.
319
+ if (uploadsOnPick()) result.added.forEach(startUpload);
208
320
  }
209
321
  showNotice(problemLabel(result.problem));
210
322
  } finally {
@@ -253,7 +365,7 @@ export function ChatView({
253
365
  setInput('');
254
366
  if (typingStopTimer.current) clearTimeout(typingStopTimer.current);
255
367
  onTyping(false);
256
- scrollToBottom();
368
+ followSend.current = true;
257
369
 
258
370
  if (drafts.length === 0) {
259
371
  // The failed bubble already surfaces a failure to the visitor.
@@ -262,6 +374,11 @@ export function ChatView({
262
374
  }
263
375
 
264
376
  const sent = drafts;
377
+ // Named by its token while that lasts; otherwise the file goes up again
378
+ // as the message is sent.
379
+ const sendBy = Date.now() + UPLOAD_EXPIRY_MARGIN_MS;
380
+ const stillUploaded = (draft: DraftAttachment) =>
381
+ !!draft.uploadToken && (draft.uploadExpiresAt ?? Infinity) > sendBy;
265
382
  setSending(true);
266
383
  showNotice(null);
267
384
  onSendMessage(
@@ -270,6 +387,7 @@ export function ChatView({
270
387
  name: draft.name,
271
388
  url: draft.dataUrl,
272
389
  fileUri: draft.fileUri,
390
+ uploadToken: stillUploaded(draft) ? draft.uploadToken : undefined,
273
391
  contentType: draft.contentType,
274
392
  size: draft.size,
275
393
  previewUrl: draft.previewUri,
@@ -288,6 +406,17 @@ export function ChatView({
288
406
  setInput((current) => current || trimmed);
289
407
  const status = (error as { status?: unknown } | null)?.status;
290
408
  showNotice(status === 413 ? labels.attach.tooLargeTogether : labels.failedToSend);
409
+ // An upload expired before this phone's clock said it would: put the
410
+ // files sent by token up again, once. After a lost connection the
411
+ // uploads still stand for the next try.
412
+ if (status === UPLOAD_EXPIRED_STATUS) {
413
+ sent
414
+ .filter((draft) => stillUploaded(draft) && !reuploaded.current.has(draft.id))
415
+ .forEach((draft) => {
416
+ reuploaded.current.add(draft.id);
417
+ startUpload(draft);
418
+ });
419
+ }
291
420
  })
292
421
  .finally(() => {
293
422
  if (mounted.current) setSending(false);
@@ -316,10 +445,10 @@ export function ChatView({
316
445
  phone: stringPayload(action.payload.phone),
317
446
  });
318
447
  }
448
+ followSend.current = true;
319
449
  onSendMessage(formatAgentUiActionMessage(action), undefined, action).catch(() => {});
320
- scrollToBottom();
321
450
  },
322
- [onIdentifyVisitor, onSendMessage, scrollToBottom]
451
+ [onIdentifyVisitor, onSendMessage]
323
452
  );
324
453
 
325
454
  const handleSubmitProcedureForm = useCallback(
@@ -331,10 +460,10 @@ export function ChatView({
331
460
  // A second failure shows on the bubble again.
332
461
  const handleRetryMessage = useCallback(
333
462
  (messageId: string) => {
463
+ followSend.current = true;
334
464
  onRetryMessage?.(messageId).catch(() => {});
335
- scrollToBottom();
336
465
  },
337
- [onRetryMessage, scrollToBottom]
466
+ [onRetryMessage]
338
467
  );
339
468
 
340
469
  // Recomputed only when the list changes — never on a keystroke.
@@ -356,6 +485,169 @@ export function ChatView({
356
485
  const headerGradient = [color, mixHex(color, '#000000', 0.16)];
357
486
  const headerControl = { backgroundColor: withAlpha(headerTextColor, 0.16) };
358
487
 
488
+ // Newest first, for the inverted list; each row still compares itself with
489
+ // the message sent before it.
490
+ const rows = useMemo<ThreadRow[]>(
491
+ () =>
492
+ messages
493
+ .map((message, index) => {
494
+ const previous = index > 0 ? messages[index - 1] : undefined;
495
+ return {
496
+ message,
497
+ startsDay: !previous || !isSameDay(message.createdAt, previous.createdAt),
498
+ // Name only at the top of a run, the way every messenger does it.
499
+ startsRun:
500
+ !previous ||
501
+ previous.direction === 'inbound' ||
502
+ previous.messageType === 'system' ||
503
+ previous.senderName !== message.senderName,
504
+ };
505
+ })
506
+ .reverse(),
507
+ [messages]
508
+ );
509
+
510
+ useEffect(() => {
511
+ if (!followSend.current) return;
512
+ followSend.current = false;
513
+ scrollToBottom();
514
+ }, [rows, scrollToBottom]);
515
+
516
+ // The thread's message ids when the visitor scrolled away from the newest,
517
+ // null while it's in view. Teammate replies newer than all of them are
518
+ // counted on the button. A set, not the newest id alone: that may be the
519
+ // visitor's own bubble, whose temporary id changes once the server has it.
520
+ const [awayFrom, setAwayFrom] = useState<ReadonlySet<string> | null>(null);
521
+ const rowIds = useRef<string[]>([]);
522
+ useEffect(() => {
523
+ rowIds.current = rows.map((row) => row.message.id);
524
+ }, [rows]);
525
+
526
+ const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
527
+ // Inverted: the offset is the distance up from the newest message.
528
+ const away = event.nativeEvent.contentOffset.y > JUMP_TO_LATEST_OFFSET;
529
+ setAwayFrom((current) => (away ? (current ?? new Set(rowIds.current)) : null));
530
+ }, []);
531
+
532
+ const unseenReplies = useMemo(() => {
533
+ if (!awayFrom) return 0;
534
+ let count = 0;
535
+ for (const { message } of rows) {
536
+ if (awayFrom.has(message.id)) break;
537
+ if (message.direction !== 'inbound' && message.messageType !== 'system') count += 1;
538
+ }
539
+ return count;
540
+ }, [awayFrom, rows]);
541
+
542
+ const handleJumpToLatest = useCallback(() => scrollToBottom(), [scrollToBottom]);
543
+
544
+ // Android only: it detaches rows scrolled out of view, where a large photo
545
+ // still downloading would never finish and keep its turn. Keyed as in usePhotoSource.
546
+ const handleViewableItemsChanged = useCallback(
547
+ ({ viewableItems }: { viewableItems: Array<ViewToken<ThreadRow>> }) =>
548
+ largeImageQueue.setOnScreen(
549
+ viewableItems.flatMap(({ item }) =>
550
+ (item.message.attachments ?? []).map((attachment) => attachment.id || attachment.url)
551
+ )
552
+ ),
553
+ [largeImageQueue]
554
+ );
555
+
556
+ const renderRow = useCallback<ListRenderItem<ThreadRow>>(
557
+ ({ item: { message, startsDay, startsRun } }) => {
558
+ const isAgentMessage = message.direction !== 'inbound' && message.messageType !== 'system';
559
+ return (
560
+ <View style={styles.threadRow}>
561
+ {startsDay ? (
562
+ <View style={styles.daySeparator}>
563
+ <View style={styles.dayLine} />
564
+ <Text style={styles.dayLabel}>
565
+ {formatDayLabel(message.createdAt, locale, labels)}
566
+ </Text>
567
+ <View style={styles.dayLine} />
568
+ </View>
569
+ ) : null}
570
+ {/* One malformed message (agent blocks, markdown) drops out of
571
+ the thread instead of blanking the whole chat. */}
572
+ <WidgetErrorBoundary fallback={null}>
573
+ <MessageBubble
574
+ message={message}
575
+ color={color}
576
+ labels={labels}
577
+ onOpenImage={setViewerGallery}
578
+ onRateConversation={onRateConversation}
579
+ locales={locales}
580
+ senderLabel={
581
+ isAgentMessage && (startsRun || startsDay)
582
+ ? message.senderName || agentName || labels.team
583
+ : null
584
+ }
585
+ timeLabel={formatClockTime(message.createdAt, locale)}
586
+ receiptLabel={
587
+ message.id === thread.lastVisitorMessageId
588
+ ? receiptLabel(message, labels)
589
+ : undefined
590
+ }
591
+ onSubmitProcedureForm={
592
+ message.id === thread.procedureFormMessageId ? handleSubmitProcedureForm : undefined
593
+ }
594
+ onAgentUiAction={
595
+ thread.agentBlocksShown.has(message.id) ? handleAgentUiAction : undefined
596
+ }
597
+ onRetry={onRetryMessage && canRetryMessage(message) ? handleRetryMessage : undefined}
598
+ photoAlbums={photoAlbums}
599
+ />
600
+ </WidgetErrorBoundary>
601
+ </View>
602
+ );
603
+ },
604
+ [
605
+ agentName,
606
+ color,
607
+ handleAgentUiAction,
608
+ handleRetryMessage,
609
+ handleSubmitProcedureForm,
610
+ labels,
611
+ locale,
612
+ locales,
613
+ onRateConversation,
614
+ onRetryMessage,
615
+ photoAlbums,
616
+ thread,
617
+ ]
618
+ );
619
+
620
+ const loadEarlierRow = hasMoreMessages ? (
621
+ <Pressable onPress={handleLoadEarlier} accessibilityRole="button" style={styles.loadMore}>
622
+ <Text style={[styles.loadMoreLabel, { color }]}>
623
+ {loading ? labels.loading : labels.loadEarlier}
624
+ </Text>
625
+ </Pressable>
626
+ ) : null;
627
+
628
+ // Under the newest message.
629
+ const threadTail =
630
+ awaitingTeammate || agentTyping ? (
631
+ <View style={styles.threadTail}>
632
+ {awaitingTeammate ? (
633
+ <View style={styles.waitingRow}>
634
+ <TeamAvatars avatars={teamAvatars} size={20} color={color} />
635
+ <Text style={styles.waitingText}>{labels.waitingForTeammate}</Text>
636
+ </View>
637
+ ) : null}
638
+ {agentTyping ? (
639
+ <View style={styles.typingRow}>
640
+ <View style={styles.typingBubble}>
641
+ <TypingDot index={0} reduceMotion={reduceMotion} />
642
+ <TypingDot index={1} reduceMotion={reduceMotion} />
643
+ <TypingDot index={2} reduceMotion={reduceMotion} />
644
+ </View>
645
+ <Text style={styles.typingLabel}>{labels.thinking}</Text>
646
+ </View>
647
+ ) : null}
648
+ </View>
649
+ ) : null;
650
+
359
651
  return (
360
652
  <View style={globalStyles.screen}>
361
653
  {/* One bar instead of a brand band above a separate back row: the band
@@ -447,117 +739,89 @@ export function ChatView({
447
739
  </Pressable>
448
740
  </GradientBackground>
449
741
 
450
- <ScrollView
451
- ref={scrollRef}
452
- style={globalStyles.fill}
453
- contentContainerStyle={[
454
- styles.messagesContent,
455
- isEmptyThread && styles.messagesContentEmpty,
456
- isLoadingThread && styles.messagesContentLoading,
457
- ]}
458
- keyboardShouldPersistTaps="handled"
459
- // Interactive dismissal drags the keyboard down under the finger while
460
- // the composer stays where the last keyboard event left it, which opens
461
- // a gap under the thread; on-drag hands us a normal hide event instead.
462
- keyboardDismissMode="on-drag"
463
- onLayout={handleViewportLayout}
464
- onContentSizeChange={handleContentSizeChange}>
465
- {hasMoreMessages ? (
466
- <Pressable onPress={handleLoadEarlier} accessibilityRole="button" style={styles.loadMore}>
467
- <Text style={[styles.loadMoreLabel, { color }]}>
468
- {loading ? labels.loading : labels.loadEarlier}
469
- </Text>
470
- </Pressable>
471
- ) : null}
472
-
473
- {isLoadingThread ? <MessagesSkeleton color={color} label={labels.loading} /> : null}
474
-
475
- {isEmptyThread ? (
476
- <View style={styles.emptyState}>
477
- <View style={[styles.emptyIcon, { backgroundColor: withAlpha(color, 0.08) }]}>
478
- <ChatOutlineIcon size={28} color={color} />
479
- </View>
480
- <Text style={styles.emptyText}>{labels.emptyConversation}</Text>
481
- </View>
482
- ) : null}
483
-
484
- {messages.map((message, index) => {
485
- const previous = index > 0 ? messages[index - 1] : undefined;
486
- const startsDay = !previous || !isSameDay(message.createdAt, previous.createdAt);
487
- const isAgentMessage =
488
- message.direction !== 'inbound' && message.messageType !== 'system';
489
- // Name only at the top of a run, the way every messenger does it.
490
- const startsRun =
491
- !previous ||
492
- previous.direction === 'inbound' ||
493
- previous.messageType === 'system' ||
494
- previous.senderName !== message.senderName;
495
-
496
- return (
497
- <Fragment key={message.id}>
498
- {startsDay ? (
499
- <View style={styles.daySeparator}>
500
- <View style={styles.dayLine} />
501
- <Text style={styles.dayLabel}>
502
- {formatDayLabel(message.createdAt, locale, labels)}
742
+ {messages.length > 0 ? (
743
+ <View style={globalStyles.fill}>
744
+ <ImageQueueContext.Provider value={largeImageQueue}>
745
+ <FlatList
746
+ ref={listRef}
747
+ data={rows}
748
+ inverted
749
+ keyExtractor={rowKey}
750
+ renderItem={renderRow}
751
+ style={globalStyles.fill}
752
+ contentContainerStyle={styles.threadContent}
753
+ ItemSeparatorComponent={ThreadGap}
754
+ // Inverted, the header sits under the newest message and the footer
755
+ // above the oldest.
756
+ ListHeaderComponent={threadTail}
757
+ ListHeaderComponentStyle={styles.threadTailInList}
758
+ ListFooterComponent={loadEarlierRow}
759
+ onEndReached={handleLoadEarlier}
760
+ onEndReachedThreshold={0.5}
761
+ onScroll={handleScroll}
762
+ scrollEventThrottle={100}
763
+ // A reply arriving below doesn't move what a visitor reads further
764
+ // up; near the newest message the list follows it into view.
765
+ maintainVisibleContentPosition={{
766
+ minIndexForVisible: 0,
767
+ autoscrollToTopThreshold: JUMP_TO_LATEST_OFFSET,
768
+ }}
769
+ onViewableItemsChanged={Platform.OS === 'android' ? handleViewableItemsChanged : undefined}
770
+ keyboardShouldPersistTaps="handled"
771
+ // Interactive dismissal drags the keyboard down under the finger while
772
+ // the composer stays where the last keyboard event left it, which opens
773
+ // a gap under the thread; on-drag hands us a normal hide event instead.
774
+ keyboardDismissMode="on-drag"
775
+ />
776
+ </ImageQueueContext.Provider>
777
+ {awayFrom ? (
778
+ <Pressable
779
+ onPress={handleJumpToLatest}
780
+ accessibilityRole="button"
781
+ accessibilityLabel={
782
+ unseenReplies
783
+ ? formatLabel(labels.jumpToLatestNew, { count: unseenReplies })
784
+ : labels.jumpToLatest
785
+ }
786
+ hitSlop={8}
787
+ style={({ pressed }) => [styles.jumpToLatest, pressed && globalStyles.pressed]}>
788
+ <ChevronIcon size={18} direction="down" color={palette.text} />
789
+ {unseenReplies ? (
790
+ <View style={[styles.jumpBadge, { backgroundColor: color }]}>
791
+ <Text style={[styles.jumpBadgeText, { color: contrastingTextColor(color) }]}>
792
+ {unseenReplies > 9 ? '9+' : unseenReplies}
503
793
  </Text>
504
- <View style={styles.dayLine} />
505
794
  </View>
506
795
  ) : null}
507
- {/* One malformed message (agent blocks, markdown) drops out of
508
- the thread instead of blanking the whole chat. */}
509
- <WidgetErrorBoundary fallback={null}>
510
- <MessageBubble
511
- message={message}
512
- color={color}
513
- labels={labels}
514
- onOpenImage={setViewerImage}
515
- onRateConversation={onRateConversation}
516
- locales={locales}
517
- senderLabel={
518
- isAgentMessage && (startsRun || startsDay)
519
- ? message.senderName || agentName || labels.team
520
- : null
521
- }
522
- timeLabel={formatClockTime(message.createdAt, locale)}
523
- receiptLabel={
524
- message.id === thread.lastVisitorMessageId
525
- ? receiptLabel(message, labels)
526
- : undefined
527
- }
528
- onSubmitProcedureForm={
529
- message.id === thread.procedureFormMessageId
530
- ? handleSubmitProcedureForm
531
- : undefined
532
- }
533
- onAgentUiAction={
534
- thread.agentBlocksShown.has(message.id) ? handleAgentUiAction : undefined
535
- }
536
- onRetry={onRetryMessage && canRetryMessage(message) ? handleRetryMessage : undefined}
537
- />
538
- </WidgetErrorBoundary>
539
- </Fragment>
540
- );
541
- })}
542
-
543
- {awaitingTeammate ? (
544
- <View style={styles.waitingRow}>
545
- <TeamAvatars avatars={teamAvatars} size={20} color={color} />
546
- <Text style={styles.waitingText}>{labels.waitingForTeammate}</Text>
547
- </View>
548
- ) : null}
549
-
550
- {agentTyping ? (
551
- <View style={styles.typingRow}>
552
- <View style={styles.typingBubble}>
553
- <TypingDot index={0} reduceMotion={reduceMotion} />
554
- <TypingDot index={1} reduceMotion={reduceMotion} />
555
- <TypingDot index={2} reduceMotion={reduceMotion} />
796
+ </Pressable>
797
+ ) : null}
798
+ </View>
799
+ ) : (
800
+ <ScrollView
801
+ style={globalStyles.fill}
802
+ contentContainerStyle={[
803
+ styles.messagesContent,
804
+ isEmptyThread && styles.messagesContentEmpty,
805
+ isLoadingThread && styles.messagesContentLoading,
806
+ ]}
807
+ keyboardShouldPersistTaps="handled"
808
+ keyboardDismissMode="on-drag">
809
+ {loadEarlierRow}
810
+
811
+ {isLoadingThread ? <MessagesSkeleton color={color} label={labels.loading} /> : null}
812
+
813
+ {isEmptyThread ? (
814
+ <View style={styles.emptyState}>
815
+ <View style={[styles.emptyIcon, { backgroundColor: withAlpha(color, 0.08) }]}>
816
+ <ChatOutlineIcon size={28} color={color} />
817
+ </View>
818
+ <Text style={styles.emptyText}>{labels.emptyConversation}</Text>
556
819
  </View>
557
- <Text style={styles.typingLabel}>{labels.thinking}</Text>
558
- </View>
559
- ) : null}
560
- </ScrollView>
820
+ ) : null}
821
+
822
+ {threadTail}
823
+ </ScrollView>
824
+ )}
561
825
 
562
826
  {offlineMessage ? (
563
827
  <View style={styles.offlineBanner}>
@@ -578,11 +842,19 @@ export function ChatView({
578
842
  <AttachmentChips
579
843
  attachments={drafts}
580
844
  disabled={sending}
581
- removeLabel={labels.attach.remove}
845
+ labels={labels.attach}
582
846
  onRemove={(id) => {
847
+ uploads.current.get(id)?.cancel();
848
+ uploads.current.delete(id);
583
849
  setDrafts((current) => current.filter((draft) => draft.id !== id));
584
850
  showNotice(null);
585
851
  }}
852
+ onRetry={(id) => {
853
+ const draft = drafts.find((item) => item.id === id);
854
+ if (!draft) return;
855
+ showNotice(null);
856
+ startUpload(draft);
857
+ }}
586
858
  />
587
859
  ) : null}
588
860
  <View
@@ -623,6 +895,7 @@ export function ChatView({
623
895
  onPress={handleSend}
624
896
  disabled={!canSend}
625
897
  accessibilityRole="button"
898
+ accessibilityLabel={labels.send}
626
899
  accessibilityState={{ disabled: !canSend }}
627
900
  hitSlop={8}
628
901
  style={({ pressed }) => [
@@ -648,11 +921,11 @@ export function ChatView({
648
921
  />
649
922
  ) : null}
650
923
  <ImageViewer
651
- image={viewerImage}
924
+ gallery={viewerGallery}
652
925
  labels={labels.viewer}
653
- onClose={() => setViewerImage(null)}
926
+ onClose={() => setViewerGallery(null)}
654
927
  onOpenExternally={(url) => {
655
- setViewerImage(null);
928
+ setViewerGallery(null);
656
929
  void openExternalUrl(url);
657
930
  }}
658
931
  />
@@ -677,6 +950,7 @@ const MessageBubble = memo(function MessageBubble({
677
950
  onRateConversation,
678
951
  locales,
679
952
  onRetry,
953
+ photoAlbums,
680
954
  }: {
681
955
  message: Message;
682
956
  color: string;
@@ -686,11 +960,13 @@ const MessageBubble = memo(function MessageBubble({
686
960
  receiptLabel?: string;
687
961
  onSubmitProcedureForm?: (body: string) => void;
688
962
  onAgentUiAction?: (action: AgentUiAction) => void | Promise<void>;
689
- onOpenImage: (image: { url: string; name: string }) => void;
963
+ onOpenImage: (gallery: ViewerGallery) => void;
690
964
  onRateConversation?: ChatViewProps['onRateConversation'];
691
965
  locales: Array<string | null | undefined>;
692
966
  /** Re-sends this message when it failed. */
693
967
  onRetry?: (messageId: string) => void;
968
+ /** Off: each photo shows on its own, full width. */
969
+ photoAlbums: boolean;
694
970
  }) {
695
971
  const joinedName = message.metadata?.event === 'agent_joined' ? message.metadata?.agentName : null;
696
972
  if (message.messageType === 'system' && typeof joinedName === 'string' && joinedName) {
@@ -753,6 +1029,54 @@ const MessageBubble = memo(function MessageBubble({
753
1029
  const procedureForm = !isVisitor ? getProcedureForm(message.metadata) : null;
754
1030
  const agentUiBlocks = !isVisitor ? getAgentUiBlocks(message.metadata) : [];
755
1031
  const textColor = isVisitor ? palette.white : palette.ink;
1032
+ // A reply with images between its paragraphs shows them where the teammate put them.
1033
+ const inlineParts = isVisitor ? null : splitInlineAttachments(message.body, message.attachments);
1034
+ const files = inlineParts ? inlineParts.files : (message.attachments ?? []);
1035
+ // Two or more photos share one album; any other files follow it.
1036
+ const photos = files.filter((attachment) => isImageAttachment(attachment.contentType));
1037
+ const album = photoAlbums && photos.length > 1 ? photos : null;
1038
+ const sentAt = Date.parse(message.createdAt) || 0;
1039
+ // A tap on one of the message's photos opens them all, at that one, in the
1040
+ // order the bubble shows them.
1041
+ const openPhotos = (tapped: { id: string; url: string; name: string }) => {
1042
+ const photos = [
1043
+ ...(inlineParts?.parts.flatMap((part) => ('attachment' in part ? [part.attachment] : [])) ?? []),
1044
+ ...files,
1045
+ ]
1046
+ .filter((attachment) => isImageAttachment(attachment.contentType))
1047
+ .map((attachment) => {
1048
+ const url =
1049
+ attachment.id === tapped.id ? tapped.url : localPreviewFor(attachment.url) || attachment.url;
1050
+ // The phone's copy may be gone by now; the stored one stands in.
1051
+ const fallbackUrl = url !== attachment.url && isHttpUrl(attachment.url) ? attachment.url : undefined;
1052
+ return {
1053
+ id: attachment.id,
1054
+ url,
1055
+ name: attachment.name,
1056
+ large: isLargeDownload(url, attachment.size),
1057
+ fallbackUrl,
1058
+ };
1059
+ })
1060
+ .filter((photo) => !!photo.url);
1061
+ const index = photos.findIndex((photo) => photo.id === tapped.id);
1062
+ onOpenImage(
1063
+ index < 0
1064
+ ? { images: [tapped], index: 0 }
1065
+ : { images: photos.map(({ url, name, large, fallbackUrl }) => ({ url, name, large, fallbackUrl })), index }
1066
+ );
1067
+ };
1068
+ const renderAttachment = (attachment: WidgetAttachment) => (
1069
+ <AttachmentPreview
1070
+ key={attachment.id}
1071
+ loadPriority={sentAt}
1072
+ attachment={attachment}
1073
+ isVisitor={isVisitor}
1074
+ color={color}
1075
+ voiceLabels={labels.voice}
1076
+ openLabel={labels.openAttachment}
1077
+ onOpenImage={openPhotos}
1078
+ />
1079
+ );
756
1080
 
757
1081
  return (
758
1082
  <View style={[styles.messageRow, isVisitor ? styles.messageRowRight : styles.messageRowLeft]}>
@@ -764,7 +1088,17 @@ const MessageBubble = memo(function MessageBubble({
764
1088
  rtl && (isVisitor ? styles.bubbleVisitorRTL : styles.bubbleAgentRTL),
765
1089
  message.status === 'pending' && styles.bubblePending,
766
1090
  ]}>
767
- {hasBody ? (
1091
+ {inlineParts ? (
1092
+ <View style={styles.inlineParts}>
1093
+ {inlineParts.parts.map((part, index) =>
1094
+ 'text' in part ? (
1095
+ <Markdown key={`text-${index}`} content={part.text} color={textColor} />
1096
+ ) : (
1097
+ renderAttachment(part.attachment)
1098
+ )
1099
+ )}
1100
+ </View>
1101
+ ) : hasBody ? (
768
1102
  isVisitor ? (
769
1103
  <Text style={[styles.bubbleText, { color: textColor }]}>{messageText}</Text>
770
1104
  ) : (
@@ -772,19 +1106,20 @@ const MessageBubble = memo(function MessageBubble({
772
1106
  )
773
1107
  ) : null}
774
1108
 
775
- {message.attachments && message.attachments.length > 0 ? (
776
- <View style={[styles.attachments, hasBody && styles.attachmentsSpaced]}>
777
- {message.attachments.map((attachment) => (
778
- <AttachmentPreview
779
- key={attachment.id}
780
- attachment={attachment}
781
- isVisitor={isVisitor}
782
- color={color}
783
- voiceLabels={labels.voice}
784
- openLabel={labels.openAttachment}
785
- onOpenImage={onOpenImage}
1109
+ {files.length > 0 ? (
1110
+ <View
1111
+ style={[styles.attachments, (hasBody || !!inlineParts) && styles.attachmentsSpaced]}>
1112
+ {album ? (
1113
+ <PhotoAlbum
1114
+ photos={album}
1115
+ loadPriority={sentAt}
1116
+ moreLabel={labels.morePhotos}
1117
+ onOpen={openPhotos}
786
1118
  />
787
- ))}
1119
+ ) : null}
1120
+ {(album ? files.filter((attachment) => !album.includes(attachment)) : files).map(
1121
+ renderAttachment
1122
+ )}
788
1123
  </View>
789
1124
  ) : null}
790
1125
 
@@ -828,6 +1163,7 @@ const MessageBubble = memo(function MessageBubble({
828
1163
 
829
1164
  {agentUiBlocks.length === 0 && procedureForm && onSubmitProcedureForm ? (
830
1165
  <ProcedureFieldForm
1166
+ messageId={message.id}
831
1167
  form={procedureForm}
832
1168
  color={color}
833
1169
  labels={labels}
@@ -844,8 +1180,181 @@ function receiptLabel(message: Message, labels: ChatLabels): string | undefined
844
1180
  return message.status === 'read' ? labels.messageSeen : labels.messageSent;
845
1181
  }
846
1182
 
1183
+ /**
1184
+ * Where a photo shows from, and whether it may load yet. `url` is empty when
1185
+ * the server sent a non-http(s) URL (see resolveUrl). The visitor's own
1186
+ * attachment points at the picked file until the server copy replaces it (or
1187
+ * is empty without one). A photo they sent this session keeps showing from
1188
+ * the phone after that: the stored copy may not load yet. Whichever fails to
1189
+ * load gives way to the next.
1190
+ */
1191
+ function usePhotoSource(attachment: WidgetAttachment, loadPriority: number) {
1192
+ const [failedUrls, setFailedUrls] = useState<string[]>([]);
1193
+ const [loadedUrl, setLoadedUrl] = useState<string | null>(null);
1194
+ const url = [localPreviewFor(attachment.url), attachment.url].find(
1195
+ (candidate): candidate is string => !!candidate && !failedUrls.includes(candidate)
1196
+ );
1197
+ const isPhoto = isImageAttachment(attachment.contentType) && !!url;
1198
+ // A large download waits its turn; small photos and the phone's own copy don't.
1199
+ const [turn, settled] = useImageTurn(
1200
+ attachment.id || attachment.url,
1201
+ loadPriority,
1202
+ isPhoto && isLargeDownload(url!, attachment.size)
1203
+ );
1204
+ return {
1205
+ url,
1206
+ isPhoto,
1207
+ turn,
1208
+ /** Waiting for its turn or still downloading; the next URL after a failure starts over. */
1209
+ loading: isPhoto && loadedUrl !== url,
1210
+ onLoad: () => {
1211
+ settled();
1212
+ if (url) setLoadedUrl(url);
1213
+ },
1214
+ onError: () => {
1215
+ settled();
1216
+ if (!url) return;
1217
+ setFailedUrls((current) => [...current, url]);
1218
+ // The viewer and the message's other views stop offering it too.
1219
+ if (url !== attachment.url && url === localPreviewFor(attachment.url)) forgetLocalPreview(attachment.url);
1220
+ },
1221
+ };
1222
+ }
1223
+
1224
+ /**
1225
+ * Several photos in one message, the way WhatsApp shows them: one square,
1226
+ * two side by side, three as one tall beside two stacked, four in a grid, and
1227
+ * past four the last tile covered with how many more there are.
1228
+ */
1229
+ function PhotoAlbum({
1230
+ photos,
1231
+ loadPriority,
1232
+ moreLabel,
1233
+ onOpen,
1234
+ }: {
1235
+ photos: WidgetAttachment[];
1236
+ loadPriority: number;
1237
+ /** Keeps `{count}`. */
1238
+ moreLabel: string;
1239
+ onOpen: (image: { id: string; url: string; name: string }) => void;
1240
+ }) {
1241
+ const shown = photos.slice(0, 4);
1242
+ // The covered tile counts too: it's no more visible than the ones after it.
1243
+ const more = photos.length > 4 ? photos.length - 3 : 0;
1244
+ const tile = (attachment: WidgetAttachment, index: number, size: StyleProp<ViewStyle>) => (
1245
+ <PhotoTile
1246
+ key={attachment.id}
1247
+ attachment={attachment}
1248
+ loadPriority={loadPriority}
1249
+ style={size}
1250
+ more={index === 3 ? more : 0}
1251
+ moreLabel={moreLabel}
1252
+ onOpen={onOpen}
1253
+ />
1254
+ );
1255
+
1256
+ if (shown.length === 2) {
1257
+ return <View style={[styles.album, styles.albumRow]}>{shown.map((photo, i) => tile(photo, i, styles.albumHalf))}</View>;
1258
+ }
1259
+ if (shown.length === 3) {
1260
+ return (
1261
+ <View style={[styles.album, styles.albumRow]}>
1262
+ {tile(shown[0]!, 0, styles.albumHalf)}
1263
+ <View style={styles.albumColumn}>
1264
+ {shown.slice(1).map((photo, i) => tile(photo, i + 1, styles.albumQuarter))}
1265
+ </View>
1266
+ </View>
1267
+ );
1268
+ }
1269
+ return <View style={[styles.album, styles.albumGrid]}>{shown.map((photo, i) => tile(photo, i, styles.albumQuarter))}</View>;
1270
+ }
1271
+
1272
+ function PhotoTile({
1273
+ attachment,
1274
+ loadPriority,
1275
+ style,
1276
+ more,
1277
+ moreLabel,
1278
+ onOpen,
1279
+ }: {
1280
+ attachment: WidgetAttachment;
1281
+ loadPriority: number;
1282
+ style: StyleProp<ViewStyle>;
1283
+ /** Photos past this tile, shown over it; 0 for none. */
1284
+ more: number;
1285
+ moreLabel: string;
1286
+ onOpen: (image: { id: string; url: string; name: string }) => void;
1287
+ }) {
1288
+ const photo = usePhotoSource(attachment, loadPriority);
1289
+ // One that can't load still opens, so the viewer can offer the browser.
1290
+ const openUrl = photo.url ?? (isHttpUrl(attachment.url) ? attachment.url : undefined);
1291
+ return (
1292
+ <Pressable
1293
+ onPress={openUrl ? () => onOpen({ id: attachment.id, url: openUrl, name: attachment.name }) : undefined}
1294
+ accessibilityRole="imagebutton"
1295
+ accessibilityLabel={
1296
+ more > 0 ? `${attachment.name}, ${formatLabel(moreLabel, { count: more })}` : attachment.name
1297
+ }
1298
+ style={({ pressed }) => [styles.albumTile, style, pressed && globalStyles.pressed]}>
1299
+ {photo.isPhoto && photo.turn ? (
1300
+ <Image
1301
+ source={{ uri: photo.url }}
1302
+ style={styles.albumImage}
1303
+ resizeMode="cover"
1304
+ // Decoded at the tile's size, like a single photo.
1305
+ resizeMethod="resize"
1306
+ accessibilityIgnoresInvertColors
1307
+ onLoad={photo.onLoad}
1308
+ onError={photo.onError}
1309
+ />
1310
+ ) : null}
1311
+ {!photo.isPhoto ? (
1312
+ // Nothing left to load (an expired link): its type and name, not a blank square.
1313
+ <View style={styles.albumFallback}>
1314
+ <Text style={styles.albumFallbackType}>{fileTypeLabel(attachment.name, attachment.contentType)}</Text>
1315
+ <Text style={styles.albumFallbackName} numberOfLines={2}>
1316
+ {attachment.name}
1317
+ </Text>
1318
+ </View>
1319
+ ) : null}
1320
+ {/* The "+N" cover already hides this tile, so it doesn't spin too. */}
1321
+ <PhotoLoader loading={photo.loading && more === 0} />
1322
+ {more > 0 ? (
1323
+ <View style={styles.albumMore}>
1324
+ <Text style={styles.albumMoreText}>+{more}</Text>
1325
+ </View>
1326
+ ) : null}
1327
+ </Pressable>
1328
+ );
1329
+ }
1330
+
1331
+ /**
1332
+ * Spins over a photo until it has loaded. Held back a moment, so a photo that
1333
+ * comes straight from the cache never flashes it.
1334
+ */
1335
+ function PhotoLoader({ loading }: { loading: boolean }) {
1336
+ const [due, setDue] = useState(false);
1337
+ useEffect(() => {
1338
+ if (!loading) return undefined;
1339
+ const timer = setTimeout(() => setDue(true), PHOTO_LOADER_DELAY_MS);
1340
+ return () => {
1341
+ clearTimeout(timer);
1342
+ setDue(false);
1343
+ };
1344
+ }, [loading]);
1345
+ if (!loading || !due) return null;
1346
+ return (
1347
+ <View style={styles.photoLoader} pointerEvents="none">
1348
+ <View style={styles.photoLoaderBadge}>
1349
+ <ActivityIndicator size="small" color={palette.white} />
1350
+ </View>
1351
+ </View>
1352
+ );
1353
+ }
1354
+
847
1355
  function AttachmentPreview({
848
1356
  attachment,
1357
+ loadPriority,
849
1358
  isVisitor,
850
1359
  color,
851
1360
  voiceLabels,
@@ -853,25 +1362,26 @@ function AttachmentPreview({
853
1362
  onOpenImage,
854
1363
  }: {
855
1364
  attachment: WidgetAttachment;
1365
+ /** Among large images waiting to download, higher goes first: newer messages. */
1366
+ loadPriority: number;
856
1367
  isVisitor: boolean;
857
1368
  color: string;
858
1369
  voiceLabels: VoiceNoteLabels;
859
1370
  /** Keeps `{name}`. */
860
1371
  openLabel: string;
861
- onOpenImage: (image: { url: string; name: string }) => void;
1372
+ /** With the URL this bubble shows, which the viewer opens at. */
1373
+ onOpenImage: (image: { id: string; url: string; name: string }) => void;
862
1374
  }) {
863
- // `url` is empty when the server sent a non-http(s) URL (see resolveUrl).
864
- // The visitor's own attachment points at the picked file until the server
865
- // copy replaces it (or is empty without one).
866
- const [imageFailed, setImageFailed] = useState(false);
867
1375
  const [audioFailed, setAudioFailed] = useState(false);
868
- const isImage = isImageAttachment(attachment.contentType) && !!attachment.url && !imageFailed;
1376
+ const photo = usePhotoSource(attachment, loadPriority);
1377
+ const imageUrl = photo.url;
1378
+ const isImage = photo.isPhoto;
869
1379
  const openable = isHttpUrl(attachment.url);
870
1380
  // Images open in the in-app viewer, a local photo too since it already
871
1381
  // renders here. Other files (PDF, sheets) need the OS, which can only open
872
1382
  // the server copy.
873
1383
  const open = isImage
874
- ? () => onOpenImage({ url: attachment.url, name: attachment.name })
1384
+ ? () => onOpenImage({ id: attachment.id, url: imageUrl!, name: attachment.name })
875
1385
  : openable
876
1386
  ? () => void openExternalUrl(attachment.url)
877
1387
  : undefined;
@@ -899,15 +1409,26 @@ function AttachmentPreview({
899
1409
  onPress={open}
900
1410
  accessibilityRole="imagebutton"
901
1411
  accessibilityLabel={attachment.name}>
902
- <Image
903
- source={{ uri: attachment.url }}
904
- style={styles.attachmentImage}
905
- resizeMode="cover"
906
- accessibilityIgnoresInvertColors
907
- // An image that can't load (expired link, deleted file) falls back to
908
- // the file row below instead of an empty box.
909
- onError={() => setImageFailed(true)}
910
- />
1412
+ {photo.turn ? (
1413
+ <Image
1414
+ source={{ uri: imageUrl }}
1415
+ style={styles.attachmentImage}
1416
+ resizeMode="cover"
1417
+ // Android otherwise decodes a server image at full size: two 20 MP
1418
+ // photos fill its shared bitmap budget, and every later image in
1419
+ // the app fails to load. Decoded at the bubble's size instead.
1420
+ resizeMethod="resize"
1421
+ accessibilityIgnoresInvertColors
1422
+ onLoad={photo.onLoad}
1423
+ // An image that can't load (expired link, deleted file) falls back to
1424
+ // the file row below instead of an empty box.
1425
+ onError={photo.onError}
1426
+ />
1427
+ ) : (
1428
+ // Same box as the image, so nothing moves when its turn comes.
1429
+ <View style={styles.attachmentImage} />
1430
+ )}
1431
+ <PhotoLoader loading={photo.loading} />
911
1432
  </Pressable>
912
1433
  );
913
1434
  }
@@ -926,19 +1447,22 @@ function AttachmentPreview({
926
1447
  }
927
1448
 
928
1449
  function ProcedureFieldForm({
1450
+ messageId,
929
1451
  form,
930
1452
  color,
931
1453
  labels,
932
1454
  onSubmit,
933
1455
  }: {
1456
+ /** Keeps what was typed while the row is scrolled far away and unmounted. */
1457
+ messageId: string;
934
1458
  form: AgentProcedureForm;
935
1459
  color: string;
936
1460
  labels: ChatLabels;
937
1461
  onSubmit: (body: string) => void;
938
1462
  }) {
939
- const [values, setValues] = useState<Record<string, string>>({});
940
- const [submitted, setSubmitted] = useState(false);
941
- const [expanded, setExpanded] = useState(false);
1463
+ const [values, setValues] = useRetainedState<Record<string, string>>(`${messageId}:values`, {});
1464
+ const [submitted, setSubmitted] = useRetainedState(`${messageId}:submitted`, false);
1465
+ const [expanded, setExpanded] = useRetainedState(`${messageId}:expanded`, false);
942
1466
 
943
1467
  const canSubmit = form.fields.every((field) => !field.required || values[field.name]?.trim());
944
1468