@repzo/widget-react-native 1.3.1 → 1.5.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 (240) hide show
  1. package/CHANGELOG.md +173 -5
  2. package/README.md +99 -13
  3. package/lib/commonjs/api/client.js +125 -28
  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/index.js.map +1 -1
  14. package/lib/commonjs/locales.js +12 -3
  15. package/lib/commonjs/locales.js.map +1 -1
  16. package/lib/commonjs/state/user-update-queue.js +168 -0
  17. package/lib/commonjs/state/user-update-queue.js.map +1 -0
  18. package/lib/commonjs/state/widget-store.js +303 -33
  19. package/lib/commonjs/state/widget-store.js.map +1 -1
  20. package/lib/commonjs/storage.js +3 -1
  21. package/lib/commonjs/storage.js.map +1 -1
  22. package/lib/commonjs/types.js.map +1 -1
  23. package/lib/commonjs/ui/WidgetProvider.js +33 -13
  24. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  25. package/lib/commonjs/ui/components/AttachmentChips.js +109 -40
  26. package/lib/commonjs/ui/components/AttachmentChips.js.map +1 -1
  27. package/lib/commonjs/ui/components/ConversationRow.js +65 -10
  28. package/lib/commonjs/ui/components/ConversationRow.js.map +1 -1
  29. package/lib/commonjs/ui/components/ImageViewer.js +229 -24
  30. package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
  31. package/lib/commonjs/ui/components/RatingCard.js +5 -2
  32. package/lib/commonjs/ui/components/RatingCard.js.map +1 -1
  33. package/lib/commonjs/ui/components/ZoomableImage.js +58 -10
  34. package/lib/commonjs/ui/components/ZoomableImage.js.map +1 -1
  35. package/lib/commonjs/ui/hooks/use-retained-state.js +39 -0
  36. package/lib/commonjs/ui/hooks/use-retained-state.js.map +1 -0
  37. package/lib/commonjs/ui/screens/chat/ChatView.js +651 -165
  38. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  39. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +138 -1
  40. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  41. package/lib/commonjs/ui/screens/conversations/ConversationsList.js +1 -0
  42. package/lib/commonjs/ui/screens/conversations/ConversationsList.js.map +1 -1
  43. package/lib/commonjs/ui/screens/home/HomeView.js +38 -55
  44. package/lib/commonjs/ui/screens/home/HomeView.js.map +1 -1
  45. package/lib/commonjs/ui/screens/home/HomeView.styles.js +36 -45
  46. package/lib/commonjs/ui/screens/home/HomeView.styles.js.map +1 -1
  47. package/lib/commonjs/ui/utils/html.js +13 -0
  48. package/lib/commonjs/ui/utils/html.js.map +1 -1
  49. package/lib/commonjs/ui/utils/image-load-queue.js +114 -0
  50. package/lib/commonjs/ui/utils/image-load-queue.js.map +1 -0
  51. package/lib/commonjs/ui/utils/inline-attachments.js +88 -0
  52. package/lib/commonjs/ui/utils/inline-attachments.js.map +1 -0
  53. package/lib/module/api/client.js +123 -27
  54. package/lib/module/api/client.js.map +1 -1
  55. package/lib/module/attachments/file-size.js +47 -0
  56. package/lib/module/attachments/file-size.js.map +1 -0
  57. package/lib/module/attachments/local-previews.js +33 -0
  58. package/lib/module/attachments/local-previews.js.map +1 -0
  59. package/lib/module/attachments/pickers.js +15 -25
  60. package/lib/module/attachments/pickers.js.map +1 -1
  61. package/lib/module/i18n.js +22 -3
  62. package/lib/module/i18n.js.map +1 -1
  63. package/lib/module/index.js.map +1 -1
  64. package/lib/module/locales.js +12 -3
  65. package/lib/module/locales.js.map +1 -1
  66. package/lib/module/state/user-update-queue.js +163 -0
  67. package/lib/module/state/user-update-queue.js.map +1 -0
  68. package/lib/module/state/widget-store.js +305 -35
  69. package/lib/module/state/widget-store.js.map +1 -1
  70. package/lib/module/storage.js +2 -0
  71. package/lib/module/storage.js.map +1 -1
  72. package/lib/module/types.js.map +1 -1
  73. package/lib/module/ui/WidgetProvider.js +33 -13
  74. package/lib/module/ui/WidgetProvider.js.map +1 -1
  75. package/lib/module/ui/components/AttachmentChips.js +111 -42
  76. package/lib/module/ui/components/AttachmentChips.js.map +1 -1
  77. package/lib/module/ui/components/ConversationRow.js +65 -10
  78. package/lib/module/ui/components/ConversationRow.js.map +1 -1
  79. package/lib/module/ui/components/ImageViewer.js +231 -26
  80. package/lib/module/ui/components/ImageViewer.js.map +1 -1
  81. package/lib/module/ui/components/RatingCard.js +5 -2
  82. package/lib/module/ui/components/RatingCard.js.map +1 -1
  83. package/lib/module/ui/components/ZoomableImage.js +58 -10
  84. package/lib/module/ui/components/ZoomableImage.js.map +1 -1
  85. package/lib/module/ui/hooks/use-retained-state.js +34 -0
  86. package/lib/module/ui/hooks/use-retained-state.js.map +1 -0
  87. package/lib/module/ui/screens/chat/ChatView.js +655 -169
  88. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  89. package/lib/module/ui/screens/chat/ChatView.styles.js +140 -2
  90. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  91. package/lib/module/ui/screens/conversations/ConversationsList.js +1 -0
  92. package/lib/module/ui/screens/conversations/ConversationsList.js.map +1 -1
  93. package/lib/module/ui/screens/home/HomeView.js +40 -57
  94. package/lib/module/ui/screens/home/HomeView.js.map +1 -1
  95. package/lib/module/ui/screens/home/HomeView.styles.js +36 -45
  96. package/lib/module/ui/screens/home/HomeView.styles.js.map +1 -1
  97. package/lib/module/ui/utils/html.js +12 -0
  98. package/lib/module/ui/utils/html.js.map +1 -1
  99. package/lib/module/ui/utils/image-load-queue.js +109 -0
  100. package/lib/module/ui/utils/image-load-queue.js.map +1 -0
  101. package/lib/module/ui/utils/inline-attachments.js +85 -0
  102. package/lib/module/ui/utils/inline-attachments.js.map +1 -0
  103. package/lib/typescript/commonjs/api/client.d.ts +20 -6
  104. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  105. package/lib/typescript/commonjs/attachments/file-size.d.ts +15 -0
  106. package/lib/typescript/commonjs/attachments/file-size.d.ts.map +1 -0
  107. package/lib/typescript/commonjs/attachments/local-previews.d.ts +14 -0
  108. package/lib/typescript/commonjs/attachments/local-previews.d.ts.map +1 -0
  109. package/lib/typescript/commonjs/attachments/pickers.d.ts +15 -2
  110. package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/i18n.d.ts +12 -3
  112. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  113. package/lib/typescript/commonjs/index.d.ts +5 -0
  114. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  115. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/state/user-update-queue.d.ts +62 -0
  117. package/lib/typescript/commonjs/state/user-update-queue.d.ts.map +1 -0
  118. package/lib/typescript/commonjs/state/widget-store.d.ts +44 -1
  119. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  120. package/lib/typescript/commonjs/storage.d.ts +2 -0
  121. package/lib/typescript/commonjs/storage.d.ts.map +1 -1
  122. package/lib/typescript/commonjs/types.d.ts +53 -0
  123. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  124. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  125. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts +13 -3
  126. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts.map +1 -1
  127. package/lib/typescript/commonjs/ui/components/ConversationRow.d.ts +15 -1
  128. package/lib/typescript/commonjs/ui/components/ConversationRow.d.ts.map +1 -1
  129. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +26 -6
  130. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
  131. package/lib/typescript/commonjs/ui/components/RatingCard.d.ts.map +1 -1
  132. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts +10 -1
  133. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts.map +1 -1
  134. package/lib/typescript/commonjs/ui/hooks/use-retained-state.d.ts +11 -0
  135. package/lib/typescript/commonjs/ui/hooks/use-retained-state.d.ts.map +1 -0
  136. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +4 -4
  137. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  138. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +144 -0
  139. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  140. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +26 -1
  141. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  142. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.d.ts.map +1 -1
  143. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.types.d.ts +2 -0
  144. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.types.d.ts.map +1 -1
  145. package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts +7 -5
  146. package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts.map +1 -1
  147. package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts +30 -35
  148. package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts.map +1 -1
  149. package/lib/typescript/commonjs/ui/screens/home/HomeView.types.d.ts +5 -6
  150. package/lib/typescript/commonjs/ui/screens/home/HomeView.types.d.ts.map +1 -1
  151. package/lib/typescript/commonjs/ui/utils/html.d.ts +5 -0
  152. package/lib/typescript/commonjs/ui/utils/html.d.ts.map +1 -1
  153. package/lib/typescript/commonjs/ui/utils/image-load-queue.d.ts +44 -0
  154. package/lib/typescript/commonjs/ui/utils/image-load-queue.d.ts.map +1 -0
  155. package/lib/typescript/commonjs/ui/utils/inline-attachments.d.ts +29 -0
  156. package/lib/typescript/commonjs/ui/utils/inline-attachments.d.ts.map +1 -0
  157. package/lib/typescript/module/api/client.d.ts +20 -6
  158. package/lib/typescript/module/api/client.d.ts.map +1 -1
  159. package/lib/typescript/module/attachments/file-size.d.ts +15 -0
  160. package/lib/typescript/module/attachments/file-size.d.ts.map +1 -0
  161. package/lib/typescript/module/attachments/local-previews.d.ts +14 -0
  162. package/lib/typescript/module/attachments/local-previews.d.ts.map +1 -0
  163. package/lib/typescript/module/attachments/pickers.d.ts +15 -2
  164. package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
  165. package/lib/typescript/module/i18n.d.ts +12 -3
  166. package/lib/typescript/module/i18n.d.ts.map +1 -1
  167. package/lib/typescript/module/index.d.ts +5 -0
  168. package/lib/typescript/module/index.d.ts.map +1 -1
  169. package/lib/typescript/module/locales.d.ts.map +1 -1
  170. package/lib/typescript/module/state/user-update-queue.d.ts +62 -0
  171. package/lib/typescript/module/state/user-update-queue.d.ts.map +1 -0
  172. package/lib/typescript/module/state/widget-store.d.ts +44 -1
  173. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  174. package/lib/typescript/module/storage.d.ts +2 -0
  175. package/lib/typescript/module/storage.d.ts.map +1 -1
  176. package/lib/typescript/module/types.d.ts +53 -0
  177. package/lib/typescript/module/types.d.ts.map +1 -1
  178. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  179. package/lib/typescript/module/ui/components/AttachmentChips.d.ts +13 -3
  180. package/lib/typescript/module/ui/components/AttachmentChips.d.ts.map +1 -1
  181. package/lib/typescript/module/ui/components/ConversationRow.d.ts +15 -1
  182. package/lib/typescript/module/ui/components/ConversationRow.d.ts.map +1 -1
  183. package/lib/typescript/module/ui/components/ImageViewer.d.ts +26 -6
  184. package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
  185. package/lib/typescript/module/ui/components/RatingCard.d.ts.map +1 -1
  186. package/lib/typescript/module/ui/components/ZoomableImage.d.ts +10 -1
  187. package/lib/typescript/module/ui/components/ZoomableImage.d.ts.map +1 -1
  188. package/lib/typescript/module/ui/hooks/use-retained-state.d.ts +11 -0
  189. package/lib/typescript/module/ui/hooks/use-retained-state.d.ts.map +1 -0
  190. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +4 -4
  191. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  192. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +144 -0
  193. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  194. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +26 -1
  195. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  196. package/lib/typescript/module/ui/screens/conversations/ConversationsList.d.ts.map +1 -1
  197. package/lib/typescript/module/ui/screens/conversations/ConversationsList.types.d.ts +2 -0
  198. package/lib/typescript/module/ui/screens/conversations/ConversationsList.types.d.ts.map +1 -1
  199. package/lib/typescript/module/ui/screens/home/HomeView.d.ts +7 -5
  200. package/lib/typescript/module/ui/screens/home/HomeView.d.ts.map +1 -1
  201. package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts +30 -35
  202. package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts.map +1 -1
  203. package/lib/typescript/module/ui/screens/home/HomeView.types.d.ts +5 -6
  204. package/lib/typescript/module/ui/screens/home/HomeView.types.d.ts.map +1 -1
  205. package/lib/typescript/module/ui/utils/html.d.ts +5 -0
  206. package/lib/typescript/module/ui/utils/html.d.ts.map +1 -1
  207. package/lib/typescript/module/ui/utils/image-load-queue.d.ts +44 -0
  208. package/lib/typescript/module/ui/utils/image-load-queue.d.ts.map +1 -0
  209. package/lib/typescript/module/ui/utils/inline-attachments.d.ts +29 -0
  210. package/lib/typescript/module/ui/utils/inline-attachments.d.ts.map +1 -0
  211. package/package.json +1 -1
  212. package/src/api/client.ts +149 -38
  213. package/src/attachments/file-size.ts +42 -0
  214. package/src/attachments/local-previews.ts +32 -0
  215. package/src/attachments/pickers.ts +23 -25
  216. package/src/i18n.ts +22 -3
  217. package/src/index.tsx +5 -0
  218. package/src/locales.ts +12 -3
  219. package/src/state/user-update-queue.ts +178 -0
  220. package/src/state/widget-store.ts +346 -51
  221. package/src/storage.ts +2 -0
  222. package/src/types.ts +53 -0
  223. package/src/ui/WidgetProvider.tsx +35 -8
  224. package/src/ui/components/AttachmentChips.tsx +100 -29
  225. package/src/ui/components/ConversationRow.tsx +90 -9
  226. package/src/ui/components/ImageViewer.tsx +242 -24
  227. package/src/ui/components/RatingCard.tsx +5 -2
  228. package/src/ui/components/ZoomableImage.tsx +58 -5
  229. package/src/ui/hooks/use-retained-state.ts +35 -0
  230. package/src/ui/screens/chat/ChatView.styles.ts +139 -1
  231. package/src/ui/screens/chat/ChatView.tsx +735 -198
  232. package/src/ui/screens/chat/ChatView.types.ts +26 -1
  233. package/src/ui/screens/conversations/ConversationsList.tsx +1 -0
  234. package/src/ui/screens/conversations/ConversationsList.types.ts +2 -0
  235. package/src/ui/screens/home/HomeView.styles.ts +36 -45
  236. package/src/ui/screens/home/HomeView.tsx +54 -70
  237. package/src/ui/screens/home/HomeView.types.ts +5 -6
  238. package/src/ui/utils/html.ts +26 -0
  239. package/src/ui/utils/image-load-queue.ts +108 -0
  240. package/src/ui/utils/inline-attachments.ts +71 -0
@@ -7,6 +7,7 @@ exports.ChatView = ChatView;
7
7
  var _react = require("react");
8
8
  var _reactNative = require("react-native");
9
9
  var _agentUiBlocks = require("../../../agent-ui-blocks.js");
10
+ var _localPreviews = require("../../../attachments/local-previews.js");
10
11
  var _pickers = require("../../../attachments/pickers.js");
11
12
  var _i18n = require("../../../i18n.js");
12
13
  var _AgentUiBlocks = require("../../components/AgentUiBlocks.js");
@@ -21,11 +22,14 @@ var _icons = require("../../components/icons.js");
21
22
  var _TeamAvatars = require("../../components/TeamAvatars.js");
22
23
  var _Markdown = require("../../components/Markdown.js");
23
24
  var _useReduceMotion = require("../../hooks/use-reduce-motion.js");
25
+ var _useRetainedState = require("../../hooks/use-retained-state.js");
24
26
  var _useTopInset = require("../../hooks/use-top-inset.js");
25
27
  var _global = require("../../styles/global.js");
26
28
  var _theme = require("../../styles/theme.js");
27
29
  var _conversations = require("../../utils/conversations.js");
28
30
  var _html = require("../../utils/html.js");
31
+ var _imageLoadQueue = require("../../utils/image-load-queue.js");
32
+ var _inlineAttachments = require("../../utils/inline-attachments.js");
29
33
  var _safeUrl = require("../../utils/safe-url.js");
30
34
  var _voice = require("../../../audio/voice.js");
31
35
  var _VoiceNote = require("../../components/VoiceNote.js");
@@ -38,12 +42,63 @@ var _jsxRuntime = require("react/jsx-runtime");
38
42
  * Chat thread — message list, typing indicator and composer.
39
43
  * Port of `client/src/widget/components/ChatView.tsx`.
40
44
  *
41
- * Mobile differences: older messages load through an explicit "Load earlier
42
- * messages" control (a scroll-anchored infinite list fights the keyboard), and
43
- * the attach button only shows when the host app has a picker to open (see
45
+ * Mobile differences: the thread is an inverted list, so it opens on the
46
+ * newest message and older pages load as the visitor scrolls up, and the
47
+ * attach button only shows when the host app has a picker to open (see
44
48
  * `attachments/pickers.ts`).
45
49
  */
46
50
 
51
+ /** A message with what its place in the thread decides about it. */
52
+
53
+ const rowKey = row => row.message.id;
54
+
55
+ /** How far above the newest message the visitor scrolls before "jump to latest" shows. */
56
+ const JUMP_TO_LATEST_OFFSET = 240;
57
+
58
+ /**
59
+ * Workstation's refusal of a message that names an expired upload. Only this
60
+ * one puts the files up again: a deleted conversation or a rejected message
61
+ * would refuse the fresh uploads too.
62
+ */
63
+ const UPLOAD_EXPIRED_STATUS = 410;
64
+ /**
65
+ * An upload this close to expiring goes up again with the message instead of
66
+ * being named by its token, which could lapse before the message arrives.
67
+ */
68
+ const UPLOAD_EXPIRY_MARGIN_MS = 10 * 60 * 1000;
69
+
70
+ /**
71
+ * Photos above this take turns to download, two at a time; smaller ones load
72
+ * at once. Two 50 MB photos stay well inside Android's download pool (see
73
+ * `ImageLoadQueue`), and ordinary photos never wait.
74
+ */
75
+ const LARGE_IMAGE_BYTES = 10_000_000;
76
+ const LARGE_IMAGE_LOADS_AT_ONCE = 2;
77
+ /** A photo that loads quicker than this never shows its loader. */
78
+ const PHOTO_LOADER_DELAY_MS = 200;
79
+
80
+ /** A photo download that waits its turn; the phone's own copy never does. */
81
+ const isLargeDownload = (url, size) => (0, _safeUrl.isHttpUrl)(url) && (size ?? 0) > LARGE_IMAGE_BYTES;
82
+ const ImageQueueContext = /*#__PURE__*/(0, _react.createContext)(null);
83
+ const subscribeNowhere = () => () => {};
84
+
85
+ /**
86
+ * Whether an image may load yet, and the callback that tells the queue it
87
+ * has (or failed). Images the queue doesn't handle load right away.
88
+ */
89
+ function useImageTurn(key, priority, queued) {
90
+ const queue = (0, _react.useContext)(ImageQueueContext);
91
+ const active = !!queue && queued;
92
+ (0, _react.useEffect)(() => active ? queue.add(key, priority) : undefined, [active, queue, key, priority]);
93
+ const turn = (0, _react.useSyncExternalStore)(queue ? queue.subscribe : subscribeNowhere, () => active ? queue.canLoad(key) : true);
94
+ const settled = (0, _react.useCallback)(() => queue?.finish(key), [queue, key]);
95
+ return [turn, settled];
96
+ }
97
+ function ThreadGap() {
98
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
99
+ style: _ChatViewStyles.styles.threadGap
100
+ });
101
+ }
47
102
  function ChatView({
48
103
  messages,
49
104
  loading,
@@ -63,11 +118,13 @@ function ChatView({
63
118
  labels,
64
119
  attachmentSources = [],
65
120
  onPickAttachments,
121
+ onUploadAttachment,
66
122
  onSendMessage,
67
123
  onRetryMessage,
68
124
  onIdentifyVisitor,
69
125
  onRateConversation,
70
126
  defaultLocale,
127
+ photoAlbums = true,
71
128
  onLoadMore,
72
129
  onTyping,
73
130
  onBack,
@@ -77,30 +134,58 @@ function ChatView({
77
134
  const [composerFocused, setComposerFocused] = (0, _react.useState)(false);
78
135
  const [drafts, setDrafts] = (0, _react.useState)([]);
79
136
  const [menuOpen, setMenuOpen] = (0, _react.useState)(false);
80
- const [viewerImage, setViewerImage] = (0, _react.useState)(null);
137
+ const [viewerGallery, setViewerGallery] = (0, _react.useState)(null);
81
138
  const [picking, setPicking] = (0, _react.useState)(false);
82
139
  // Only attachment sends hold the composer; text sends stay fire-and-forget.
83
140
  const [sending, setSending] = (0, _react.useState)(false);
84
141
  const [notice, setNotice] = (0, _react.useState)(null);
85
142
  const reduceMotion = (0, _useReduceMotion.useReduceMotion)();
86
- const scrollRef = (0, _react.useRef)(null);
143
+ const listRef = (0, _react.useRef)(null);
144
+ // One per thread: the screen remounts for each conversation.
145
+ const [largeImageQueue] = (0, _react.useState)(() => new _imageLoadQueue.ImageLoadQueue(LARGE_IMAGE_LOADS_AT_ONCE));
87
146
  const typingStopTimer = (0, _react.useRef)(null);
88
147
  const noticeTimer = (0, _react.useRef)(null);
89
148
  const pickerTimer = (0, _react.useRef)(null);
90
149
  const mounted = (0, _react.useRef)(false);
150
+ // Uploads in flight, by draft id. A removed chip cancels its own; a retry
151
+ // replaces it, and the replaced attempt's result is dropped.
152
+ const uploads = (0, _react.useRef)(new Map());
153
+ // Files already put up again after a refused send; never twice, so a
154
+ // refusal uploads can't fix costs one round, not one per tap on Send.
155
+ const reuploaded = (0, _react.useRef)(new Set());
156
+ // Set as the visitor sends: the list scrolls down once their bubble is in
157
+ // it. Scrolled up, scrolling first let the list hold its place as the new
158
+ // row arrived, which could cut the scroll short.
159
+ const followSend = (0, _react.useRef)(false);
160
+ // Set by a tap on Send until the composer re-renders. Taps that pile up
161
+ // while the JS thread is busy (a slow phone, the host app at work) are all
162
+ // handled before React re-renders, so each still sees the typed text and
163
+ // would send it again. A ref changes at once: only the first goes out.
164
+ const sendTapped = (0, _react.useRef)(false);
165
+
166
+ // After every render, and after the send button has taken this render's
167
+ // handler (its own effect runs first): from here a tap sees the composer as
168
+ // the last send left it.
169
+ (0, _react.useEffect)(() => {
170
+ sendTapped.current = false;
171
+ });
91
172
  (0, _react.useEffect)(() => {
92
173
  if (prefillText) setInput(prefillText);
93
174
  }, [prefillText]);
94
175
 
95
176
  // The provider keys this screen by the store's thread generation. Pending
96
- // picks and sends belong to this mount, even before a draft gets a server ID.
177
+ // picks, uploads and sends belong to this mount, even before a draft gets a
178
+ // server ID.
97
179
  (0, _react.useEffect)(() => {
98
180
  mounted.current = true;
181
+ const inFlight = uploads.current;
99
182
  return () => {
100
183
  mounted.current = false;
101
184
  if (typingStopTimer.current) clearTimeout(typingStopTimer.current);
102
185
  if (noticeTimer.current) clearTimeout(noticeTimer.current);
103
186
  if (pickerTimer.current) clearTimeout(pickerTimer.current);
187
+ inFlight.forEach(upload => upload.cancel());
188
+ inFlight.clear();
104
189
  };
105
190
  }, []);
106
191
  const showNotice = (0, _react.useCallback)(message => {
@@ -108,50 +193,66 @@ function ChatView({
108
193
  setNotice(message);
109
194
  if (message) noticeTimer.current = setTimeout(() => setNotice(null), 5000);
110
195
  }, []);
196
+
197
+ // The list is inverted, so offset 0 is the newest message: the thread opens
198
+ // there without scrolling, stays there as the keyboard rises or replies
199
+ // arrive, and older pages land above it without moving the reader.
111
200
  const scrollToBottom = (0, _react.useCallback)((animated = true) => {
112
- scrollRef.current?.scrollToEnd({
201
+ listRef.current?.scrollToOffset({
202
+ offset: 0,
113
203
  animated
114
204
  });
115
205
  }, []);
116
206
 
117
- // Land on the newest message the moment the thread opens — notification
118
- // taps and conversation-list taps both mount this screen. The content-size
119
- // handler below covers later growth (loads, images, incoming messages).
120
- (0, _react.useEffect)(() => {
121
- const frame = requestAnimationFrame(() => scrollToBottom(false));
122
- return () => cancelAnimationFrame(frame);
123
- }, [scrollToBottom]);
124
-
125
- // "Load earlier" PREPENDS history; skipping one auto-scroll keeps the reader
126
- // anchored where they were instead of yanking them back to the bottom.
127
- const skipNextAutoScroll = (0, _react.useRef)(false);
207
+ // Fires as the visitor nears the oldest message; the row up there is the
208
+ // fallback when a page fails to load.
128
209
  const handleLoadEarlier = (0, _react.useCallback)(() => {
129
- skipNextAutoScroll.current = true;
130
- onLoadMore();
131
- }, [onLoadMore]);
132
- const handleContentSizeChange = (0, _react.useCallback)(() => {
133
- if (skipNextAutoScroll.current) {
134
- skipNextAutoScroll.current = false;
135
- return;
136
- }
137
- scrollToBottom(false);
138
- }, [scrollToBottom]);
139
-
140
- // The keyboard shortens the thread instead of scrolling it, so whatever was
141
- // at the bottom — the newest message, an agent form — would slide in behind
142
- // the composer. Re-pin on every shrink, unanimated, so the list tracks the
143
- // keyboard as it rises rather than jumping once it lands.
144
- const viewportHeight = (0, _react.useRef)(0);
145
- const handleViewportLayout = (0, _react.useCallback)(event => {
146
- const {
147
- height
148
- } = event.nativeEvent.layout;
149
- const shrank = viewportHeight.current - height > 1;
150
- viewportHeight.current = height;
151
- if (shrank) scrollToBottom(false);
152
- }, [scrollToBottom]);
210
+ if (hasMoreMessages && !loading) onLoadMore();
211
+ }, [hasMoreMessages, loading, onLoadMore]);
153
212
  const showAttach = attachmentSources.length > 0 && !!onPickAttachments;
154
- const canSend = !sending && (input.trim().length > 0 || drafts.length > 0);
213
+ // Send waits until every picked file is up, so the message goes out at once.
214
+ const uploadsPending = drafts.some(draft => draft.uploadStatus === 'uploading' || draft.uploadStatus === 'failed');
215
+ const canSend = !sending && !uploadsPending && (input.trim().length > 0 || drafts.length > 0);
216
+ const updateDraft = (0, _react.useCallback)((id, patch) => setDrafts(current => current.map(draft => draft.id === id ? {
217
+ ...draft,
218
+ ...patch
219
+ } : draft)), []);
220
+
221
+ /** Puts a picked local file up while the visitor writes; inline files need nothing. */
222
+ const startUpload = draft => {
223
+ if (!onUploadAttachment || !draft.fileUri) return;
224
+ uploads.current.get(draft.id)?.cancel();
225
+ const controller = typeof AbortController === 'function' ? new AbortController() : null;
226
+ const attempt = {
227
+ cancel: () => controller?.abort()
228
+ };
229
+ uploads.current.set(draft.id, attempt);
230
+ updateDraft(draft.id, {
231
+ uploadStatus: 'uploading',
232
+ uploadToken: undefined,
233
+ uploadExpiresAt: undefined
234
+ });
235
+ onUploadAttachment(draft, controller?.signal).then(({
236
+ uploadToken,
237
+ expiresAt
238
+ }) => {
239
+ if (!mounted.current || uploads.current.get(draft.id) !== attempt) return;
240
+ uploads.current.delete(draft.id);
241
+ updateDraft(draft.id, {
242
+ uploadStatus: 'uploaded',
243
+ uploadToken,
244
+ uploadExpiresAt: expiresAt
245
+ });
246
+ }, error => {
247
+ if (!mounted.current || uploads.current.get(draft.id) !== attempt) return;
248
+ console.warn('[repzo-widget] attachment upload failed', error);
249
+ uploads.current.delete(draft.id);
250
+ updateDraft(draft.id, {
251
+ uploadStatus: 'failed'
252
+ });
253
+ showNotice(labels.attach.uploadFailedNotice);
254
+ });
255
+ };
155
256
  const problemLabel = problem => {
156
257
  switch (problem) {
157
258
  case 'tooMany':
@@ -176,6 +277,8 @@ function ChatView({
176
277
  if (!mounted.current) return;
177
278
  if (result.added.length > 0) {
178
279
  setDrafts(current => [...current, ...result.added].slice(0, _pickers.MAX_ATTACHMENTS));
280
+ // Opted out, the files go up when the visitor taps Send instead.
281
+ if ((0, _pickers.uploadsOnPick)()) result.added.forEach(startUpload);
179
282
  }
180
283
  showNotice(problemLabel(result.problem));
181
284
  } finally {
@@ -211,23 +314,29 @@ function ChatView({
211
314
  };
212
315
  const handleSend = () => {
213
316
  const trimmed = input.trim();
214
- if (!canSend) return;
317
+ if (!canSend || sendTapped.current) return;
318
+ sendTapped.current = true;
215
319
  setInput('');
216
320
  if (typingStopTimer.current) clearTimeout(typingStopTimer.current);
217
321
  onTyping(false);
218
- scrollToBottom();
322
+ followSend.current = true;
219
323
  if (drafts.length === 0) {
220
324
  // The failed bubble already surfaces a failure to the visitor.
221
325
  onSendMessage(trimmed).catch(() => {});
222
326
  return;
223
327
  }
224
328
  const sent = drafts;
329
+ // Named by its token while that lasts; otherwise the file goes up again
330
+ // as the message is sent.
331
+ const sendBy = Date.now() + UPLOAD_EXPIRY_MARGIN_MS;
332
+ const stillUploaded = draft => !!draft.uploadToken && (draft.uploadExpiresAt ?? Infinity) > sendBy;
225
333
  setSending(true);
226
334
  showNotice(null);
227
335
  onSendMessage(trimmed, sent.map(draft => ({
228
336
  name: draft.name,
229
337
  url: draft.dataUrl,
230
338
  fileUri: draft.fileUri,
339
+ uploadToken: stillUploaded(draft) ? draft.uploadToken : undefined,
231
340
  contentType: draft.contentType,
232
341
  size: draft.size,
233
342
  previewUrl: draft.previewUri
@@ -243,6 +352,15 @@ function ChatView({
243
352
  setInput(current => current || trimmed);
244
353
  const status = error?.status;
245
354
  showNotice(status === 413 ? labels.attach.tooLargeTogether : labels.failedToSend);
355
+ // An upload expired before this phone's clock said it would: put the
356
+ // files sent by token up again, once. After a lost connection the
357
+ // uploads still stand for the next try.
358
+ if (status === UPLOAD_EXPIRED_STATUS) {
359
+ sent.filter(draft => stillUploaded(draft) && !reuploaded.current.has(draft.id)).forEach(draft => {
360
+ reuploaded.current.add(draft.id);
361
+ startUpload(draft);
362
+ });
363
+ }
246
364
  }).finally(() => {
247
365
  if (mounted.current) setSending(false);
248
366
  });
@@ -262,17 +380,17 @@ function ChatView({
262
380
  phone: stringPayload(action.payload.phone)
263
381
  });
264
382
  }
383
+ followSend.current = true;
265
384
  onSendMessage((0, _agentUiBlocks.formatAgentUiActionMessage)(action), undefined, action).catch(() => {});
266
- scrollToBottom();
267
- }, [onIdentifyVisitor, onSendMessage, scrollToBottom]);
385
+ }, [onIdentifyVisitor, onSendMessage]);
268
386
  const handleSubmitProcedureForm = (0, _react.useCallback)(body => void onSendMessage(body).catch(() => {}), [onSendMessage]);
269
387
 
270
388
  // The retried bubble moves to the end of the thread; follow it there.
271
389
  // A second failure shows on the bubble again.
272
390
  const handleRetryMessage = (0, _react.useCallback)(messageId => {
391
+ followSend.current = true;
273
392
  onRetryMessage?.(messageId).catch(() => {});
274
- scrollToBottom();
275
- }, [onRetryMessage, scrollToBottom]);
393
+ }, [onRetryMessage]);
276
394
 
277
395
  // Recomputed only when the list changes — never on a keystroke.
278
396
  const thread = (0, _react.useMemo)(() => deriveThread(messages), [messages]);
@@ -292,6 +410,143 @@ function ChatView({
292
410
  const headerControl = {
293
411
  backgroundColor: (0, _theme.withAlpha)(headerTextColor, 0.16)
294
412
  };
413
+
414
+ // Newest first, for the inverted list; each row still compares itself with
415
+ // the message sent before it.
416
+ const rows = (0, _react.useMemo)(() => messages.map((message, index) => {
417
+ const previous = index > 0 ? messages[index - 1] : undefined;
418
+ return {
419
+ message,
420
+ startsDay: !previous || !(0, _conversations.isSameDay)(message.createdAt, previous.createdAt),
421
+ // Name only at the top of a run, the way every messenger does it.
422
+ startsRun: !previous || previous.direction === 'inbound' || previous.messageType === 'system' || previous.senderName !== message.senderName
423
+ };
424
+ }).reverse(), [messages]);
425
+ (0, _react.useEffect)(() => {
426
+ if (!followSend.current) return;
427
+ followSend.current = false;
428
+ scrollToBottom();
429
+ }, [rows, scrollToBottom]);
430
+
431
+ // The thread's message ids when the visitor scrolled away from the newest,
432
+ // null while it's in view. Teammate replies newer than all of them are
433
+ // counted on the button. A set, not the newest id alone: that may be the
434
+ // visitor's own bubble, whose temporary id changes once the server has it.
435
+ const [awayFrom, setAwayFrom] = (0, _react.useState)(null);
436
+ const rowIds = (0, _react.useRef)([]);
437
+ (0, _react.useEffect)(() => {
438
+ rowIds.current = rows.map(row => row.message.id);
439
+ }, [rows]);
440
+ const handleScroll = (0, _react.useCallback)(event => {
441
+ // Inverted: the offset is the distance up from the newest message.
442
+ const away = event.nativeEvent.contentOffset.y > JUMP_TO_LATEST_OFFSET;
443
+ setAwayFrom(current => away ? current ?? new Set(rowIds.current) : null);
444
+ }, []);
445
+ const unseenReplies = (0, _react.useMemo)(() => {
446
+ if (!awayFrom) return 0;
447
+ let count = 0;
448
+ for (const {
449
+ message
450
+ } of rows) {
451
+ if (awayFrom.has(message.id)) break;
452
+ if (message.direction !== 'inbound' && message.messageType !== 'system') count += 1;
453
+ }
454
+ return count;
455
+ }, [awayFrom, rows]);
456
+ const handleJumpToLatest = (0, _react.useCallback)(() => scrollToBottom(), [scrollToBottom]);
457
+
458
+ // Android only: it detaches rows scrolled out of view, where a large photo
459
+ // still downloading would never finish and keep its turn. Keyed as in usePhotoSource.
460
+ const handleViewableItemsChanged = (0, _react.useCallback)(({
461
+ viewableItems
462
+ }) => largeImageQueue.setOnScreen(viewableItems.flatMap(({
463
+ item
464
+ }) => (item.message.attachments ?? []).map(attachment => attachment.id || attachment.url))), [largeImageQueue]);
465
+ const renderRow = (0, _react.useCallback)(({
466
+ item: {
467
+ message,
468
+ startsDay,
469
+ startsRun
470
+ }
471
+ }) => {
472
+ const isAgentMessage = message.direction !== 'inbound' && message.messageType !== 'system';
473
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
474
+ style: _ChatViewStyles.styles.threadRow,
475
+ children: [startsDay ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
476
+ style: _ChatViewStyles.styles.daySeparator,
477
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
478
+ style: _ChatViewStyles.styles.dayLine
479
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
480
+ style: _ChatViewStyles.styles.dayLabel,
481
+ children: (0, _conversations.formatDayLabel)(message.createdAt, locale, labels)
482
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
483
+ style: _ChatViewStyles.styles.dayLine
484
+ })]
485
+ }) : null, /*#__PURE__*/(0, _jsxRuntime.jsx)(_ErrorBoundary.WidgetErrorBoundary, {
486
+ fallback: null,
487
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(MessageBubble, {
488
+ message: message,
489
+ color: color,
490
+ labels: labels,
491
+ onOpenImage: setViewerGallery,
492
+ onRateConversation: onRateConversation,
493
+ locales: locales,
494
+ senderLabel: isAgentMessage && (startsRun || startsDay) ? message.senderName || agentName || labels.team : null,
495
+ timeLabel: (0, _conversations.formatClockTime)(message.createdAt, locale),
496
+ receiptLabel: message.id === thread.lastVisitorMessageId ? receiptLabel(message, labels) : undefined,
497
+ onSubmitProcedureForm: message.id === thread.procedureFormMessageId ? handleSubmitProcedureForm : undefined,
498
+ onAgentUiAction: thread.agentBlocksShown.has(message.id) ? handleAgentUiAction : undefined,
499
+ onRetry: onRetryMessage && (0, _conversations.canRetryMessage)(message) ? handleRetryMessage : undefined,
500
+ photoAlbums: photoAlbums
501
+ })
502
+ })]
503
+ });
504
+ }, [agentName, color, handleAgentUiAction, handleRetryMessage, handleSubmitProcedureForm, labels, locale, locales, onRateConversation, onRetryMessage, photoAlbums, thread]);
505
+ const loadEarlierRow = hasMoreMessages ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Pressable, {
506
+ onPress: handleLoadEarlier,
507
+ accessibilityRole: "button",
508
+ style: _ChatViewStyles.styles.loadMore,
509
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
510
+ style: [_ChatViewStyles.styles.loadMoreLabel, {
511
+ color
512
+ }],
513
+ children: loading ? labels.loading : labels.loadEarlier
514
+ })
515
+ }) : null;
516
+
517
+ // Under the newest message.
518
+ const threadTail = awaitingTeammate || agentTyping ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
519
+ style: _ChatViewStyles.styles.threadTail,
520
+ children: [awaitingTeammate ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
521
+ style: _ChatViewStyles.styles.waitingRow,
522
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_TeamAvatars.TeamAvatars, {
523
+ avatars: teamAvatars,
524
+ size: 20,
525
+ color: color
526
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
527
+ style: _ChatViewStyles.styles.waitingText,
528
+ children: labels.waitingForTeammate
529
+ })]
530
+ }) : null, agentTyping ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
531
+ style: _ChatViewStyles.styles.typingRow,
532
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
533
+ style: _ChatViewStyles.styles.typingBubble,
534
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(TypingDot, {
535
+ index: 0,
536
+ reduceMotion: reduceMotion
537
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(TypingDot, {
538
+ index: 1,
539
+ reduceMotion: reduceMotion
540
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(TypingDot, {
541
+ index: 2,
542
+ reduceMotion: reduceMotion
543
+ })]
544
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
545
+ style: _ChatViewStyles.styles.typingLabel,
546
+ children: labels.thinking
547
+ })]
548
+ }) : null]
549
+ }) : null;
295
550
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
296
551
  style: _global.globalStyles.screen,
297
552
  children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_Gradient.GradientBackground, {
@@ -396,29 +651,76 @@ function ChatView({
396
651
  color: headerTextColor
397
652
  })
398
653
  })]
399
- }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.ScrollView, {
400
- ref: scrollRef,
654
+ }), messages.length > 0 ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
401
655
  style: _global.globalStyles.fill,
402
- contentContainerStyle: [_ChatViewStyles.styles.messagesContent, isEmptyThread && _ChatViewStyles.styles.messagesContentEmpty, isLoadingThread && _ChatViewStyles.styles.messagesContentLoading],
403
- keyboardShouldPersistTaps: "handled"
404
- // Interactive dismissal drags the keyboard down under the finger while
405
- // the composer stays where the last keyboard event left it, which opens
406
- // a gap under the thread; on-drag hands us a normal hide event instead.
407
- ,
408
- keyboardDismissMode: "on-drag",
409
- onLayout: handleViewportLayout,
410
- onContentSizeChange: handleContentSizeChange,
411
- children: [hasMoreMessages ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Pressable, {
412
- onPress: handleLoadEarlier,
656
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(ImageQueueContext.Provider, {
657
+ value: largeImageQueue,
658
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.FlatList, {
659
+ ref: listRef,
660
+ data: rows,
661
+ inverted: true,
662
+ keyExtractor: rowKey,
663
+ renderItem: renderRow,
664
+ style: _global.globalStyles.fill,
665
+ contentContainerStyle: _ChatViewStyles.styles.threadContent,
666
+ ItemSeparatorComponent: ThreadGap
667
+ // Inverted, the header sits under the newest message and the footer
668
+ // above the oldest.
669
+ ,
670
+ ListHeaderComponent: threadTail,
671
+ ListHeaderComponentStyle: _ChatViewStyles.styles.threadTailInList,
672
+ ListFooterComponent: loadEarlierRow,
673
+ onEndReached: handleLoadEarlier,
674
+ onEndReachedThreshold: 0.5,
675
+ onScroll: handleScroll,
676
+ scrollEventThrottle: 100
677
+ // A reply arriving below doesn't move what a visitor reads further
678
+ // up; near the newest message the list follows it into view.
679
+ ,
680
+ maintainVisibleContentPosition: {
681
+ minIndexForVisible: 0,
682
+ autoscrollToTopThreshold: JUMP_TO_LATEST_OFFSET
683
+ },
684
+ onViewableItemsChanged: _reactNative.Platform.OS === 'android' ? handleViewableItemsChanged : undefined,
685
+ keyboardShouldPersistTaps: "handled"
686
+ // Interactive dismissal drags the keyboard down under the finger while
687
+ // the composer stays where the last keyboard event left it, which opens
688
+ // a gap under the thread; on-drag hands us a normal hide event instead.
689
+ ,
690
+ keyboardDismissMode: "on-drag"
691
+ })
692
+ }), awayFrom ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Pressable, {
693
+ onPress: handleJumpToLatest,
413
694
  accessibilityRole: "button",
414
- style: _ChatViewStyles.styles.loadMore,
415
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
416
- style: [_ChatViewStyles.styles.loadMoreLabel, {
417
- color
695
+ accessibilityLabel: unseenReplies ? (0, _i18n.formatLabel)(labels.jumpToLatestNew, {
696
+ count: unseenReplies
697
+ }) : labels.jumpToLatest,
698
+ hitSlop: 8,
699
+ style: ({
700
+ pressed
701
+ }) => [_ChatViewStyles.styles.jumpToLatest, pressed && _global.globalStyles.pressed],
702
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_icons.ChevronIcon, {
703
+ size: 18,
704
+ direction: "down",
705
+ color: _theme.palette.text
706
+ }), unseenReplies ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
707
+ style: [_ChatViewStyles.styles.jumpBadge, {
708
+ backgroundColor: color
418
709
  }],
419
- children: loading ? labels.loading : labels.loadEarlier
420
- })
421
- }) : null, isLoadingThread ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_MessagesSkeleton.MessagesSkeleton, {
710
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
711
+ style: [_ChatViewStyles.styles.jumpBadgeText, {
712
+ color: (0, _theme.contrastingTextColor)(color)
713
+ }],
714
+ children: unseenReplies > 9 ? '9+' : unseenReplies
715
+ })
716
+ }) : null]
717
+ }) : null]
718
+ }) : /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.ScrollView, {
719
+ style: _global.globalStyles.fill,
720
+ contentContainerStyle: [_ChatViewStyles.styles.messagesContent, isEmptyThread && _ChatViewStyles.styles.messagesContentEmpty, isLoadingThread && _ChatViewStyles.styles.messagesContentLoading],
721
+ keyboardShouldPersistTaps: "handled",
722
+ keyboardDismissMode: "on-drag",
723
+ children: [loadEarlierRow, isLoadingThread ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_MessagesSkeleton.MessagesSkeleton, {
422
724
  color: color,
423
725
  label: labels.loading
424
726
  }) : null, isEmptyThread ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
@@ -435,70 +737,7 @@ function ChatView({
435
737
  style: _ChatViewStyles.styles.emptyText,
436
738
  children: labels.emptyConversation
437
739
  })]
438
- }) : null, messages.map((message, index) => {
439
- const previous = index > 0 ? messages[index - 1] : undefined;
440
- const startsDay = !previous || !(0, _conversations.isSameDay)(message.createdAt, previous.createdAt);
441
- const isAgentMessage = message.direction !== 'inbound' && message.messageType !== 'system';
442
- // Name only at the top of a run, the way every messenger does it.
443
- const startsRun = !previous || previous.direction === 'inbound' || previous.messageType === 'system' || previous.senderName !== message.senderName;
444
- return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_react.Fragment, {
445
- children: [startsDay ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
446
- style: _ChatViewStyles.styles.daySeparator,
447
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
448
- style: _ChatViewStyles.styles.dayLine
449
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
450
- style: _ChatViewStyles.styles.dayLabel,
451
- children: (0, _conversations.formatDayLabel)(message.createdAt, locale, labels)
452
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
453
- style: _ChatViewStyles.styles.dayLine
454
- })]
455
- }) : null, /*#__PURE__*/(0, _jsxRuntime.jsx)(_ErrorBoundary.WidgetErrorBoundary, {
456
- fallback: null,
457
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(MessageBubble, {
458
- message: message,
459
- color: color,
460
- labels: labels,
461
- onOpenImage: setViewerImage,
462
- onRateConversation: onRateConversation,
463
- locales: locales,
464
- senderLabel: isAgentMessage && (startsRun || startsDay) ? message.senderName || agentName || labels.team : null,
465
- timeLabel: (0, _conversations.formatClockTime)(message.createdAt, locale),
466
- receiptLabel: message.id === thread.lastVisitorMessageId ? receiptLabel(message, labels) : undefined,
467
- onSubmitProcedureForm: message.id === thread.procedureFormMessageId ? handleSubmitProcedureForm : undefined,
468
- onAgentUiAction: thread.agentBlocksShown.has(message.id) ? handleAgentUiAction : undefined,
469
- onRetry: onRetryMessage && (0, _conversations.canRetryMessage)(message) ? handleRetryMessage : undefined
470
- })
471
- })]
472
- }, message.id);
473
- }), awaitingTeammate ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
474
- style: _ChatViewStyles.styles.waitingRow,
475
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_TeamAvatars.TeamAvatars, {
476
- avatars: teamAvatars,
477
- size: 20,
478
- color: color
479
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
480
- style: _ChatViewStyles.styles.waitingText,
481
- children: labels.waitingForTeammate
482
- })]
483
- }) : null, agentTyping ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
484
- style: _ChatViewStyles.styles.typingRow,
485
- children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
486
- style: _ChatViewStyles.styles.typingBubble,
487
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(TypingDot, {
488
- index: 0,
489
- reduceMotion: reduceMotion
490
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(TypingDot, {
491
- index: 1,
492
- reduceMotion: reduceMotion
493
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(TypingDot, {
494
- index: 2,
495
- reduceMotion: reduceMotion
496
- })]
497
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
498
- style: _ChatViewStyles.styles.typingLabel,
499
- children: labels.thinking
500
- })]
501
- }) : null]
740
+ }) : null, threadTail]
502
741
  }), offlineMessage ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
503
742
  style: _ChatViewStyles.styles.offlineBanner,
504
743
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
@@ -515,10 +754,18 @@ function ChatView({
515
754
  }) : null, drafts.length > 0 ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_AttachmentChips.AttachmentChips, {
516
755
  attachments: drafts,
517
756
  disabled: sending,
518
- removeLabel: labels.attach.remove,
757
+ labels: labels.attach,
519
758
  onRemove: id => {
759
+ uploads.current.get(id)?.cancel();
760
+ uploads.current.delete(id);
520
761
  setDrafts(current => current.filter(draft => draft.id !== id));
521
762
  showNotice(null);
763
+ },
764
+ onRetry: id => {
765
+ const draft = drafts.find(item => item.id === id);
766
+ if (!draft) return;
767
+ showNotice(null);
768
+ startUpload(draft);
522
769
  }
523
770
  }) : null, /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
524
771
  style: [_ChatViewStyles.styles.composer, showAttach && _ChatViewStyles.styles.composerWithAttach, composerFocused && _ChatViewStyles.styles.composerFocused, composerFocused && {
@@ -556,6 +803,7 @@ function ChatView({
556
803
  onPress: handleSend,
557
804
  disabled: !canSend,
558
805
  accessibilityRole: "button",
806
+ accessibilityLabel: labels.send,
559
807
  accessibilityState: {
560
808
  disabled: !canSend
561
809
  },
@@ -578,11 +826,11 @@ function ChatView({
578
826
  onSelect: handleMenuSelect,
579
827
  onClose: () => setMenuOpen(false)
580
828
  }) : null, /*#__PURE__*/(0, _jsxRuntime.jsx)(_ImageViewer.ImageViewer, {
581
- image: viewerImage,
829
+ gallery: viewerGallery,
582
830
  labels: labels.viewer,
583
- onClose: () => setViewerImage(null),
831
+ onClose: () => setViewerGallery(null),
584
832
  onOpenExternally: url => {
585
- setViewerImage(null);
833
+ setViewerGallery(null);
586
834
  void (0, _safeUrl.openExternalUrl)(url);
587
835
  }
588
836
  })]
@@ -605,7 +853,8 @@ const MessageBubble = /*#__PURE__*/(0, _react.memo)(function MessageBubble({
605
853
  onOpenImage,
606
854
  onRateConversation,
607
855
  locales,
608
- onRetry
856
+ onRetry,
857
+ photoAlbums
609
858
  }) {
610
859
  const joinedName = message.metadata?.event === 'agent_joined' ? message.metadata?.agentName : null;
611
860
  if (message.messageType === 'system' && typeof joinedName === 'string' && joinedName) {
@@ -668,6 +917,56 @@ const MessageBubble = /*#__PURE__*/(0, _react.memo)(function MessageBubble({
668
917
  const procedureForm = !isVisitor ? (0, _agentUiBlocks.getProcedureForm)(message.metadata) : null;
669
918
  const agentUiBlocks = !isVisitor ? (0, _agentUiBlocks.getAgentUiBlocks)(message.metadata) : [];
670
919
  const textColor = isVisitor ? _theme.palette.white : _theme.palette.ink;
920
+ // A reply with images between its paragraphs shows them where the teammate put them.
921
+ const inlineParts = isVisitor ? null : (0, _inlineAttachments.splitInlineAttachments)(message.body, message.attachments);
922
+ const files = inlineParts ? inlineParts.files : message.attachments ?? [];
923
+ // Two or more photos share one album; any other files follow it.
924
+ const photos = files.filter(attachment => (0, _pickers.isImageAttachment)(attachment.contentType));
925
+ const album = photoAlbums && photos.length > 1 ? photos : null;
926
+ const sentAt = Date.parse(message.createdAt) || 0;
927
+ // A tap on one of the message's photos opens them all, at that one, in the
928
+ // order the bubble shows them.
929
+ const openPhotos = tapped => {
930
+ const photos = [...(inlineParts?.parts.flatMap(part => 'attachment' in part ? [part.attachment] : []) ?? []), ...files].filter(attachment => (0, _pickers.isImageAttachment)(attachment.contentType)).map(attachment => {
931
+ const url = attachment.id === tapped.id ? tapped.url : (0, _localPreviews.localPreviewFor)(attachment.url) || attachment.url;
932
+ // The phone's copy may be gone by now; the stored one stands in.
933
+ const fallbackUrl = url !== attachment.url && (0, _safeUrl.isHttpUrl)(attachment.url) ? attachment.url : undefined;
934
+ return {
935
+ id: attachment.id,
936
+ url,
937
+ name: attachment.name,
938
+ large: isLargeDownload(url, attachment.size),
939
+ fallbackUrl
940
+ };
941
+ }).filter(photo => !!photo.url);
942
+ const index = photos.findIndex(photo => photo.id === tapped.id);
943
+ onOpenImage(index < 0 ? {
944
+ images: [tapped],
945
+ index: 0
946
+ } : {
947
+ images: photos.map(({
948
+ url,
949
+ name,
950
+ large,
951
+ fallbackUrl
952
+ }) => ({
953
+ url,
954
+ name,
955
+ large,
956
+ fallbackUrl
957
+ })),
958
+ index
959
+ });
960
+ };
961
+ const renderAttachment = attachment => /*#__PURE__*/(0, _jsxRuntime.jsx)(AttachmentPreview, {
962
+ loadPriority: sentAt,
963
+ attachment: attachment,
964
+ isVisitor: isVisitor,
965
+ color: color,
966
+ voiceLabels: labels.voice,
967
+ openLabel: labels.openAttachment,
968
+ onOpenImage: openPhotos
969
+ }, attachment.id);
671
970
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
672
971
  style: [_ChatViewStyles.styles.messageRow, isVisitor ? _ChatViewStyles.styles.messageRowRight : _ChatViewStyles.styles.messageRowLeft],
673
972
  children: [senderLabel ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
@@ -677,7 +976,13 @@ const MessageBubble = /*#__PURE__*/(0, _react.memo)(function MessageBubble({
677
976
  style: [_ChatViewStyles.styles.bubble, isVisitor ? [_ChatViewStyles.styles.bubbleVisitor, {
678
977
  backgroundColor: color
679
978
  }] : _ChatViewStyles.styles.bubbleAgent, rtl && (isVisitor ? _ChatViewStyles.styles.bubbleVisitorRTL : _ChatViewStyles.styles.bubbleAgentRTL), message.status === 'pending' && _ChatViewStyles.styles.bubblePending],
680
- children: [hasBody ? isVisitor ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
979
+ children: [inlineParts ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
980
+ style: _ChatViewStyles.styles.inlineParts,
981
+ children: inlineParts.parts.map((part, index) => 'text' in part ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_Markdown.Markdown, {
982
+ content: part.text,
983
+ color: textColor
984
+ }, `text-${index}`) : renderAttachment(part.attachment))
985
+ }) : hasBody ? isVisitor ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
681
986
  style: [_ChatViewStyles.styles.bubbleText, {
682
987
  color: textColor
683
988
  }],
@@ -685,16 +990,14 @@ const MessageBubble = /*#__PURE__*/(0, _react.memo)(function MessageBubble({
685
990
  }) : /*#__PURE__*/(0, _jsxRuntime.jsx)(_Markdown.Markdown, {
686
991
  content: messageText,
687
992
  color: textColor
688
- }) : null, message.attachments && message.attachments.length > 0 ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
689
- style: [_ChatViewStyles.styles.attachments, hasBody && _ChatViewStyles.styles.attachmentsSpaced],
690
- children: message.attachments.map(attachment => /*#__PURE__*/(0, _jsxRuntime.jsx)(AttachmentPreview, {
691
- attachment: attachment,
692
- isVisitor: isVisitor,
693
- color: color,
694
- voiceLabels: labels.voice,
695
- openLabel: labels.openAttachment,
696
- onOpenImage: onOpenImage
697
- }, attachment.id))
993
+ }) : null, files.length > 0 ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
994
+ style: [_ChatViewStyles.styles.attachments, (hasBody || !!inlineParts) && _ChatViewStyles.styles.attachmentsSpaced],
995
+ children: [album ? /*#__PURE__*/(0, _jsxRuntime.jsx)(PhotoAlbum, {
996
+ photos: album,
997
+ loadPriority: sentAt,
998
+ moreLabel: labels.morePhotos,
999
+ onOpen: openPhotos
1000
+ }) : null, (album ? files.filter(attachment => !album.includes(attachment)) : files).map(renderAttachment)]
698
1001
  }) : null, timeLabel ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
699
1002
  style: [_ChatViewStyles.styles.messageTime, {
700
1003
  color: isVisitor ? (0, _theme.withAlpha)(_theme.palette.white, 0.7) : _theme.palette.textSubtle
@@ -725,6 +1028,7 @@ const MessageBubble = /*#__PURE__*/(0, _react.memo)(function MessageBubble({
725
1028
  labels: labels.blocks,
726
1029
  onAction: onAgentUiAction
727
1030
  }) : null, agentUiBlocks.length === 0 && procedureForm && onSubmitProcedureForm ? /*#__PURE__*/(0, _jsxRuntime.jsx)(ProcedureFieldForm, {
1031
+ messageId: message.id,
728
1032
  form: procedureForm,
729
1033
  color: color,
730
1034
  labels: labels,
@@ -738,26 +1042,194 @@ function receiptLabel(message, labels) {
738
1042
  if (message.deletedAt || message.status === 'pending' || message.status === 'failed') return undefined;
739
1043
  return message.status === 'read' ? labels.messageSeen : labels.messageSent;
740
1044
  }
1045
+
1046
+ /**
1047
+ * Where a photo shows from, and whether it may load yet. `url` is empty when
1048
+ * the server sent a non-http(s) URL (see resolveUrl). The visitor's own
1049
+ * attachment points at the picked file until the server copy replaces it (or
1050
+ * is empty without one). A photo they sent this session keeps showing from
1051
+ * the phone after that: the stored copy may not load yet. Whichever fails to
1052
+ * load gives way to the next.
1053
+ */
1054
+ function usePhotoSource(attachment, loadPriority) {
1055
+ const [failedUrls, setFailedUrls] = (0, _react.useState)([]);
1056
+ const [loadedUrl, setLoadedUrl] = (0, _react.useState)(null);
1057
+ const url = [(0, _localPreviews.localPreviewFor)(attachment.url), attachment.url].find(candidate => !!candidate && !failedUrls.includes(candidate));
1058
+ const isPhoto = (0, _pickers.isImageAttachment)(attachment.contentType) && !!url;
1059
+ // A large download waits its turn; small photos and the phone's own copy don't.
1060
+ const [turn, settled] = useImageTurn(attachment.id || attachment.url, loadPriority, isPhoto && isLargeDownload(url, attachment.size));
1061
+ return {
1062
+ url,
1063
+ isPhoto,
1064
+ turn,
1065
+ /** Waiting for its turn or still downloading; the next URL after a failure starts over. */
1066
+ loading: isPhoto && loadedUrl !== url,
1067
+ onLoad: () => {
1068
+ settled();
1069
+ if (url) setLoadedUrl(url);
1070
+ },
1071
+ onError: () => {
1072
+ settled();
1073
+ if (!url) return;
1074
+ setFailedUrls(current => [...current, url]);
1075
+ // The viewer and the message's other views stop offering it too.
1076
+ if (url !== attachment.url && url === (0, _localPreviews.localPreviewFor)(attachment.url)) (0, _localPreviews.forgetLocalPreview)(attachment.url);
1077
+ }
1078
+ };
1079
+ }
1080
+
1081
+ /**
1082
+ * Several photos in one message, the way WhatsApp shows them: one square,
1083
+ * two side by side, three as one tall beside two stacked, four in a grid, and
1084
+ * past four the last tile covered with how many more there are.
1085
+ */
1086
+ function PhotoAlbum({
1087
+ photos,
1088
+ loadPriority,
1089
+ moreLabel,
1090
+ onOpen
1091
+ }) {
1092
+ const shown = photos.slice(0, 4);
1093
+ // The covered tile counts too: it's no more visible than the ones after it.
1094
+ const more = photos.length > 4 ? photos.length - 3 : 0;
1095
+ const tile = (attachment, index, size) => /*#__PURE__*/(0, _jsxRuntime.jsx)(PhotoTile, {
1096
+ attachment: attachment,
1097
+ loadPriority: loadPriority,
1098
+ style: size,
1099
+ more: index === 3 ? more : 0,
1100
+ moreLabel: moreLabel,
1101
+ onOpen: onOpen
1102
+ }, attachment.id);
1103
+ if (shown.length === 2) {
1104
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
1105
+ style: [_ChatViewStyles.styles.album, _ChatViewStyles.styles.albumRow],
1106
+ children: shown.map((photo, i) => tile(photo, i, _ChatViewStyles.styles.albumHalf))
1107
+ });
1108
+ }
1109
+ if (shown.length === 3) {
1110
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
1111
+ style: [_ChatViewStyles.styles.album, _ChatViewStyles.styles.albumRow],
1112
+ children: [tile(shown[0], 0, _ChatViewStyles.styles.albumHalf), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
1113
+ style: _ChatViewStyles.styles.albumColumn,
1114
+ children: shown.slice(1).map((photo, i) => tile(photo, i + 1, _ChatViewStyles.styles.albumQuarter))
1115
+ })]
1116
+ });
1117
+ }
1118
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
1119
+ style: [_ChatViewStyles.styles.album, _ChatViewStyles.styles.albumGrid],
1120
+ children: shown.map((photo, i) => tile(photo, i, _ChatViewStyles.styles.albumQuarter))
1121
+ });
1122
+ }
1123
+ function PhotoTile({
1124
+ attachment,
1125
+ loadPriority,
1126
+ style,
1127
+ more,
1128
+ moreLabel,
1129
+ onOpen
1130
+ }) {
1131
+ const photo = usePhotoSource(attachment, loadPriority);
1132
+ // One that can't load still opens, so the viewer can offer the browser.
1133
+ const openUrl = photo.url ?? ((0, _safeUrl.isHttpUrl)(attachment.url) ? attachment.url : undefined);
1134
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Pressable, {
1135
+ onPress: openUrl ? () => onOpen({
1136
+ id: attachment.id,
1137
+ url: openUrl,
1138
+ name: attachment.name
1139
+ }) : undefined,
1140
+ accessibilityRole: "imagebutton",
1141
+ accessibilityLabel: more > 0 ? `${attachment.name}, ${(0, _i18n.formatLabel)(moreLabel, {
1142
+ count: more
1143
+ })}` : attachment.name,
1144
+ style: ({
1145
+ pressed
1146
+ }) => [_ChatViewStyles.styles.albumTile, style, pressed && _global.globalStyles.pressed],
1147
+ children: [photo.isPhoto && photo.turn ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Image, {
1148
+ source: {
1149
+ uri: photo.url
1150
+ },
1151
+ style: _ChatViewStyles.styles.albumImage,
1152
+ resizeMode: "cover"
1153
+ // Decoded at the tile's size, like a single photo.
1154
+ ,
1155
+ resizeMethod: "resize",
1156
+ accessibilityIgnoresInvertColors: true,
1157
+ onLoad: photo.onLoad,
1158
+ onError: photo.onError
1159
+ }) : null, !photo.isPhoto ?
1160
+ /*#__PURE__*/
1161
+ // Nothing left to load (an expired link): its type and name, not a blank square.
1162
+ (0, _jsxRuntime.jsxs)(_reactNative.View, {
1163
+ style: _ChatViewStyles.styles.albumFallback,
1164
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
1165
+ style: _ChatViewStyles.styles.albumFallbackType,
1166
+ children: (0, _FileCard.fileTypeLabel)(attachment.name, attachment.contentType)
1167
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
1168
+ style: _ChatViewStyles.styles.albumFallbackName,
1169
+ numberOfLines: 2,
1170
+ children: attachment.name
1171
+ })]
1172
+ }) : null, /*#__PURE__*/(0, _jsxRuntime.jsx)(PhotoLoader, {
1173
+ loading: photo.loading && more === 0
1174
+ }), more > 0 ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
1175
+ style: _ChatViewStyles.styles.albumMore,
1176
+ children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Text, {
1177
+ style: _ChatViewStyles.styles.albumMoreText,
1178
+ children: ["+", more]
1179
+ })
1180
+ }) : null]
1181
+ });
1182
+ }
1183
+
1184
+ /**
1185
+ * Spins over a photo until it has loaded. Held back a moment, so a photo that
1186
+ * comes straight from the cache never flashes it.
1187
+ */
1188
+ function PhotoLoader({
1189
+ loading
1190
+ }) {
1191
+ const [due, setDue] = (0, _react.useState)(false);
1192
+ (0, _react.useEffect)(() => {
1193
+ if (!loading) return undefined;
1194
+ const timer = setTimeout(() => setDue(true), PHOTO_LOADER_DELAY_MS);
1195
+ return () => {
1196
+ clearTimeout(timer);
1197
+ setDue(false);
1198
+ };
1199
+ }, [loading]);
1200
+ if (!loading || !due) return null;
1201
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
1202
+ style: _ChatViewStyles.styles.photoLoader,
1203
+ pointerEvents: "none",
1204
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
1205
+ style: _ChatViewStyles.styles.photoLoaderBadge,
1206
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ActivityIndicator, {
1207
+ size: "small",
1208
+ color: _theme.palette.white
1209
+ })
1210
+ })
1211
+ });
1212
+ }
741
1213
  function AttachmentPreview({
742
1214
  attachment,
1215
+ loadPriority,
743
1216
  isVisitor,
744
1217
  color,
745
1218
  voiceLabels,
746
1219
  openLabel,
747
1220
  onOpenImage
748
1221
  }) {
749
- // `url` is empty when the server sent a non-http(s) URL (see resolveUrl).
750
- // The visitor's own attachment points at the picked file until the server
751
- // copy replaces it (or is empty without one).
752
- const [imageFailed, setImageFailed] = (0, _react.useState)(false);
753
1222
  const [audioFailed, setAudioFailed] = (0, _react.useState)(false);
754
- const isImage = (0, _pickers.isImageAttachment)(attachment.contentType) && !!attachment.url && !imageFailed;
1223
+ const photo = usePhotoSource(attachment, loadPriority);
1224
+ const imageUrl = photo.url;
1225
+ const isImage = photo.isPhoto;
755
1226
  const openable = (0, _safeUrl.isHttpUrl)(attachment.url);
756
1227
  // Images open in the in-app viewer, a local photo too since it already
757
1228
  // renders here. Other files (PDF, sheets) need the OS, which can only open
758
1229
  // the server copy.
759
1230
  const open = isImage ? () => onOpenImage({
760
- url: attachment.url,
1231
+ id: attachment.id,
1232
+ url: imageUrl,
761
1233
  name: attachment.name
762
1234
  }) : openable ? () => void (0, _safeUrl.openExternalUrl)(attachment.url) : undefined;
763
1235
 
@@ -776,22 +1248,35 @@ function AttachmentPreview({
776
1248
  });
777
1249
  }
778
1250
  if (isImage) {
779
- return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Pressable, {
1251
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Pressable, {
780
1252
  onPress: open,
781
1253
  accessibilityRole: "imagebutton",
782
1254
  accessibilityLabel: attachment.name,
783
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Image, {
1255
+ children: [photo.turn ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Image, {
784
1256
  source: {
785
- uri: attachment.url
1257
+ uri: imageUrl
786
1258
  },
787
1259
  style: _ChatViewStyles.styles.attachmentImage,
788
- resizeMode: "cover",
789
- accessibilityIgnoresInvertColors: true
1260
+ resizeMode: "cover"
1261
+ // Android otherwise decodes a server image at full size: two 20 MP
1262
+ // photos fill its shared bitmap budget, and every later image in
1263
+ // the app fails to load. Decoded at the bubble's size instead.
1264
+ ,
1265
+ resizeMethod: "resize",
1266
+ accessibilityIgnoresInvertColors: true,
1267
+ onLoad: photo.onLoad
790
1268
  // An image that can't load (expired link, deleted file) falls back to
791
1269
  // the file row below instead of an empty box.
792
1270
  ,
793
- onError: () => setImageFailed(true)
794
- })
1271
+ onError: photo.onError
1272
+ }) :
1273
+ /*#__PURE__*/
1274
+ // Same box as the image, so nothing moves when its turn comes.
1275
+ (0, _jsxRuntime.jsx)(_reactNative.View, {
1276
+ style: _ChatViewStyles.styles.attachmentImage
1277
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(PhotoLoader, {
1278
+ loading: photo.loading
1279
+ })]
795
1280
  });
796
1281
  }
797
1282
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_FileCard.FileCard, {
@@ -805,14 +1290,15 @@ function AttachmentPreview({
805
1290
  });
806
1291
  }
807
1292
  function ProcedureFieldForm({
1293
+ messageId,
808
1294
  form,
809
1295
  color,
810
1296
  labels,
811
1297
  onSubmit
812
1298
  }) {
813
- const [values, setValues] = (0, _react.useState)({});
814
- const [submitted, setSubmitted] = (0, _react.useState)(false);
815
- const [expanded, setExpanded] = (0, _react.useState)(false);
1299
+ const [values, setValues] = (0, _useRetainedState.useRetainedState)(`${messageId}:values`, {});
1300
+ const [submitted, setSubmitted] = (0, _useRetainedState.useRetainedState)(`${messageId}:submitted`, false);
1301
+ const [expanded, setExpanded] = (0, _useRetainedState.useRetainedState)(`${messageId}:expanded`, false);
816
1302
  const canSubmit = form.fields.every(field => !field.required || values[field.name]?.trim());
817
1303
  const handleSubmit = () => {
818
1304
  if (!canSubmit || submitted) return;