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