@repzo/widget-react-native 1.3.1 → 1.5.0

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