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