@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
package/CHANGELOG.md CHANGED
@@ -12,20 +12,188 @@ All notable changes to `@repzo/widget-react-native`, newest first.
12
12
 
13
13
  ## Unreleased
14
14
 
15
+ ## 1.5.0 — 2026-11-06
16
+
17
+ ### Added
18
+
19
+ - **`updateUser()` is paced, like other messengers' SDKs.** An app that calls
20
+ it on a timer or on every location fix no longer sends every call. Values
21
+ Workstation already has aren't sent again. Up to 20 updates go out at once,
22
+ then one every 90 seconds (20 per 30 minutes). Calls in between are merged,
23
+ newest value per key, and sent on the next allowed update, so the latest
24
+ value of every attribute still arrives. If Workstation answers that it's
25
+ busy (HTTP 429), the SDK waits as long as it asks and sends again; after a
26
+ failed connection or a server error it tries again a minute later. New
27
+ `boot({ throttleUpdateUser })` option (default `true`). See **Upgrading**.
28
+ - **`RepzoWidgetError.retryAfter`.** On an HTTP 429, the seconds Workstation
29
+ asked to wait, when it said.
30
+
31
+ ### Fixed
32
+
33
+ - **A message goes out once, however many times Send is tapped.** On a slow
34
+ or busy phone the chat could take a moment to react to Send. Every extra tap
35
+ in that moment sent the message again, so your team got it several times.
36
+ Now only the first tap sends it. Nothing to set up.
37
+
38
+ ### Upgrading
39
+
40
+ - **`updateUser()` calls may be merged and sent later.** A call that's held
41
+ back resolves right away instead of waiting for, or failing on, the
42
+ request. A call that's sent straight away still rejects when the request
43
+ fails, except for a 429, which the SDK now retries itself. If your app
44
+ calls `updateUser()` often, you can drop your own retry logic. To send
45
+ every call straight away, as before:
46
+
47
+ ```ts
48
+ await RepzoWidget.boot({ channelAccountId: '<widget id>', throttleUpdateUser: false });
49
+ ```
50
+
51
+ ## 1.4.0 — 2026-11-02
52
+
53
+ ### Added
54
+
55
+ - **Several photos in one message show as an album,** like WhatsApp: one
56
+ square holding them all. Two sit side by side, three as one tall beside two
57
+ stacked, four in a grid, and with more than four the last tile shows how
58
+ many more there are ("+3"). Tapping any tile opens the photos at that one.
59
+ Other files in the message still show below. New `boot({ photoAlbums })`
60
+ option (default `true`): `false` shows each photo on its own, as before.
61
+ See **Upgrading**.
62
+ - **Swipe between a message's photos.** When a message has several photos,
63
+ sent or received, tapping one opens them all in the full-screen viewer.
64
+ Swipe left or right to move between them (mirrored in Arabic); the header
65
+ shows which one is open, like "2 / 4", and screen readers move between them
66
+ with the adjust gesture. The next photo is already drawn as it slides in
67
+ and stays on screen while its full-size copy loads, so a swipe never shows
68
+ a black frame. Pinch and double-tap zoom work as before. Nothing to set up.
69
+ - **Photos in the chat show a loader until they've loaded.** A small spinner
70
+ sits over each photo, and each album tile, while it downloads or waits its
71
+ turn, instead of an empty box. A photo that loads straight away (from the
72
+ cache) never shows it. Nothing to set up.
73
+ - **Choose when picked files upload.** New
74
+ `boot({ attachments: { uploadOnPick } })` option (default `true`). Set it to
75
+ `false` to upload files only when the visitor taps Send, so nothing leaves
76
+ the phone for a file they pick and then remove.
77
+ - **"You:" before the visitor's own last message.** On Home and in Messages,
78
+ a conversation whose newest message is the visitor's shows "You: " before
79
+ it ("You: Photo" for a photo), so it's clear who spoke last. Arabic:
80
+ "أنت: ". Workspaces can change it with the `lastMessageYou` translation key,
81
+ which keeps `{message}`. Workstation's list doesn't say who sent the newest
82
+ message, so the SDK remembers it from what this device sent, received or
83
+ loaded, including across launches; a conversation last written in
84
+ elsewhere shows no "You:". `Conversation` has a new optional
85
+ `lastMessageFromVisitor` field; a Workstation list that sends it is used
86
+ as is. New `boot({ youPrefix })` option (default `true`): `false` shows the
87
+ message without "You:", as before. See **Upgrading**.
88
+
15
89
  ### Changed
16
90
 
91
+ - **A conversation opens on its newest message.** The thread starts at the
92
+ bottom, with the latest messages drawn first, instead of rendering from the
93
+ top and then jumping down. Older messages load on their own as the visitor
94
+ scrolls up, without moving what they're reading, so there's no need to tap
95
+ **Load earlier messages**. That row stays at the top while a page loads, or
96
+ to try again if one didn't. A reply that arrives while they read further up
97
+ doesn't move the text either. Once the visitor has scrolled up, a button in
98
+ the corner takes them back to the newest message and shows how many
99
+ teammate replies arrived in the meantime. Nothing to set up.
17
100
  - **Visitors can attach files up to 50 MB.** With a Workstation server that
18
- takes uploads, a picked file is uploaded when the message is sent, instead of
19
- being sent inside the message, so the 1.5 MB cap per file (6 MB per message)
20
- is gone; the server sets the cap (50 MB a file, up to five files). Large files
21
- are read from the device as they upload, never held in memory as text. The
22
- "File is too large" notice shows the server's cap. Nothing to set up.
101
+ takes uploads, a picked file is uploaded on its own instead of being sent
102
+ inside the message, so the 1.5 MB cap per file (6 MB per message) is gone;
103
+ the server sets the cap (50 MB a file, up to five files). With
104
+ `expo-file-system` installed, a file is sent straight from the phone's
105
+ storage as it uploads, so even a 50 MB file doesn't fill the app's memory.
106
+ Without it the file is read into memory first, which can fail on Android
107
+ for files over about 30 MB. The "File is too large" notice shows the
108
+ server's cap. Nothing else to set up.
109
+ - **Files upload as soon as they're picked.** A spinner shows on each file
110
+ in the message field while it uploads, and the send button turns on once
111
+ every file is up, so the message goes out at once. The upload happens while
112
+ the visitor writes. A file that fails to upload shows **Tap to retry**, and
113
+ removing a file cancels its upload. If the visitor waits so long to send
114
+ that Workstation no longer keeps the upload (about an hour), the file
115
+ uploads again as the message goes out. With an older Workstation that
116
+ doesn't take uploads, nothing changes. To keep uploading on Send, see
117
+ **Upgrading**.
118
+ - **Home shows the latest conversation in a "Recent message" card.** The
119
+ newest conversation now sits in its own card above **Send us a message**:
120
+ who replied, how long ago and the last thing said, with the unread count
121
+ when there is one. Tapping it opens the conversation. It replaces the
122
+ "Recent" list of two conversations and its **View all** link that sat under
123
+ the start card; every conversation is still one tap away in the Messages
124
+ tab. The card follows the workspace's "show previous conversations" setting
125
+ like the list did. Arabic: "أحدث رسالة". Workspaces can change the title
126
+ with the `homeRecentMessageTitle` translation key. Nothing to set up.
127
+
128
+ ### Fixed
129
+
130
+ - **Images in a team reply show where the teammate put them.** A reply with
131
+ text, an image, more text and another image now shows them in that order,
132
+ like the web Messenger, instead of all the text first and every image after
133
+ it. Links and formatting in the text between images are kept.
134
+ - **A photo the visitor sends stays a photo.** Right after sending, a photo
135
+ could turn into a file row until the conversation was opened again. It now
136
+ keeps showing from the phone until the next time the conversation loads.
137
+ An attachment that arrives without a file type shows as what its name says,
138
+ so a photo shows as a photo.
139
+ - **Photos keep showing in long conversations on Android.** A conversation
140
+ with a few large photos could use up the memory Android sets aside for
141
+ images. Later photos, in the chat and elsewhere in your app, then showed as
142
+ file rows or didn't load. Photos in the chat are now decoded at the size
143
+ they're shown, and photos over 10 MB download two at a time, newest first,
144
+ so several of them on screen at once all show instead of all failing.
145
+ Ordinary photos still load straight away. iOS wasn't affected.
146
+ - **Screen readers name the send button.** VoiceOver and TalkBack read
147
+ "Send message" (Arabic: "إرسال الرسالة") for the button beside the message
148
+ field, instead of just "button". Workspaces can change the text with the
149
+ `sendMessage` translation key.
150
+ - **The conversation list shows the reply that just arrived.** On Home and
151
+ in Messages, a reply that came in while the visitor was on those screens
152
+ could leave the row showing the visitor's own last message, beside the
153
+ unread badge. This happened when the reply was only a photo or file, or
154
+ came from Workstation as formatted text only. The row now shows the
155
+ reply's words, or "Photo" or "File" (Arabic: "صورة", "ملف") when it had
156
+ none. Workspaces can change these with the `lastMessagePhoto` and
157
+ `lastMessageFile` translation keys. A conversation the visitor starts with
158
+ only attachments shows the same instead of "1 attachment". For apps that
159
+ read conversations from `MessageDidArrive`, `snippet` stays text: after a
160
+ reply with only photos or files it now says "2 attachments", the way
161
+ Workstation's list words it, instead of keeping the previous message's
162
+ words. A new optional `lastMessageAttachment` field (`'photo'`, `'file'`
163
+ or `null`) says which it was.
164
+ - **The conversation list shows what the visitor just sent.** After replying
165
+ in a conversation and going back to Home or Messages, the row still showed
166
+ the team's last message until the list was pulled to refresh. It now shows
167
+ the visitor's message, and its time, as soon as it's sent.
23
168
 
24
169
  ### Older versions
25
170
 
26
171
  - Apps on earlier versions keep the 1.5 MB cap. With an older server, this
27
172
  version keeps it too.
28
173
 
174
+ ### Upgrading
175
+
176
+ - **Picked files now upload before Send.** Nothing to do. To upload them only
177
+ when the visitor taps Send, as before:
178
+
179
+ ```ts
180
+ await RepzoWidget.boot({ channelAccountId: '<widget id>', attachments: { uploadOnPick: false } });
181
+ ```
182
+
183
+ - **Several photos in one message show as an album.** Nothing to do. To keep
184
+ showing each photo on its own, full width:
185
+
186
+ ```ts
187
+ await RepzoWidget.boot({ channelAccountId: '<widget id>', photoAlbums: false });
188
+ ```
189
+
190
+ - **"You:" before the visitor's own last message** on Home and in Messages.
191
+ Nothing to do. To show the message alone, as before:
192
+
193
+ ```ts
194
+ await RepzoWidget.boot({ channelAccountId: '<widget id>', youPrefix: false });
195
+ ```
196
+
29
197
  ## 1.3.1 — 2026-10-29
30
198
 
31
199
  ### Added
package/README.md CHANGED
@@ -25,7 +25,11 @@ in your Repzo Inbox and agent replies arrive live.
25
25
  - Sent/Seen receipts, tap to retry a message that didn't send (*1.3.1+*), teammate avatars and presence, deleted-message placeholders
26
26
  - Catches up after a dropped connection or a trip to the background — no missed or duplicated replies
27
27
  - Agent UI blocks: product cards, slot pickers, lead forms, quotes, receipts
28
- - **Image viewer** with pinch and double-tap zoom for photos in the conversation
28
+ - **Image viewer** with pinch and double-tap zoom for photos in the conversation; swipe between a message's photos (*1.4.0+*)
29
+ - **Photo albums**: several photos in one message share one square, WhatsApp-style (*1.4.0+*; `photoAlbums: false` turns them off)
30
+ - Conversations open on the newest message, load older ones as the visitor scrolls up, and offer a jump-to-latest button (*1.4.0+*)
31
+ - Team replies keep their images where the teammate put them, between the text (*1.4.0+*)
32
+ - Conversation lists say "You:" before the visitor's own last message, and "Photo" or "File" for one without words (*1.4.0+*; `youPrefix: false` turns "You:" off)
29
33
  - **Photo and file attachments** (optional — with `expo-image-picker` / `expo-document-picker`, or your own picker)
30
34
  - **Voice messages play in the chat**, with the phone's own audio player — nothing to install in Expo apps
31
35
  - **In-app notification cards** for new replies while the messenger is closed
@@ -46,7 +50,7 @@ in your Repzo Inbox and agent replies arrive live.
46
50
  | `react-native-safe-area-context` | optional — safe-area insets from the host provider |
47
51
  | `expo-image-picker` | optional — "Photo library" and "Take photo" attachments (v16+ / SDK 52+) |
48
52
  | `expo-document-picker` | optional — "File" attachments |
49
- | `expo-file-system` | optional — reads picked files as base64 (falls back to `fetch` without it) |
53
+ | `expo-file-system` | optional — reads picked files as base64 (falls back to `fetch` without it), and (*1.4.0+*) lets large attachments upload straight from storage (recommended) |
50
54
  | `expo-image-manipulator` | optional — shrinks photos so camera shots fit the 1.5 MB attachment limit |
51
55
  | `expo-audio` | optional (*1.2.2+*) — no longer needed from *1.3.0+* in apps with Expo modules (see "Voice messages"); still used where the SDK's own player isn't built in |
52
56
  | `expo-crypto` | optional (*1.1.0+*) — secure random source for the anonymous device id when the runtime has no `crypto` global (see below) |
@@ -320,7 +324,7 @@ npx expo install expo-image-picker expo-document-picker expo-file-system expo-im
320
324
  | --- | --- |
321
325
  | `expo-image-picker` | Photo library, Take photo |
322
326
  | `expo-document-picker` | File |
323
- | `expo-file-system` | nothing new — reads picked files faster (SDK 53's `readAsStringAsync` and SDK 54+'s `File` both work) |
327
+ | `expo-file-system` | nothing new — reads picked files faster (SDK 53's `readAsStringAsync` and SDK 54+'s `File` both work), and (*1.4.0+*) uploads large files straight from storage. Without it a file is read into memory before it uploads, which can fail on Android for files over about 30 MB (recommended) |
324
328
  | `expo-image-manipulator` | nothing new — shrinks photos so camera shots fit the size limit (recommended) |
325
329
 
326
330
  With none of them (and no `pick` below) the button isn't shown. It is also
@@ -334,15 +338,25 @@ them. Taking photos needs `NSCameraUsageDescription` in `Info.plist` (the
334
338
  gets a clear message instead of a silently missing file:
335
339
 
336
340
  - 5 files per message
337
- - 1.5 MB per file
338
- - 6 MB for all files in one message together (they travel base64-encoded in
339
- one JSON request)
341
+ - *1.4.0+.* 50 MB per file, the cap Workstation sets. Each file starts uploading as
342
+ soon as it's picked, with a spinner on its thumbnail, and Send turns on
343
+ once every file is up, so the message then goes out at once. A file that
344
+ fails to upload shows **Tap to retry**; removing it cancels its upload.
345
+ A file picked about an hour before Send uploads again as the message goes
346
+ out, since Workstation stops keeping an unsent upload by then.
347
+ - Before 1.4.0, or with an older Workstation that doesn't take uploads,
348
+ files travel base64-encoded inside the message instead: 1.5 MB per file,
349
+ and 6 MB for all files in one message together.
350
+
351
+ *1.4.0+.* To upload the files only when the visitor taps Send, so nothing
352
+ leaves the phone for a file they pick and then remove, pass
353
+ `attachments: { uploadOnPick: false }`. Send then stays on while files are
354
+ attached, and the message goes out once its files are up.
340
355
 
341
356
  With `expo-image-manipulator` installed, photos are scaled to at most 1600 px
342
- on the longest edge and saved as 70% JPEG — typically 200–500 KB, so camera
343
- shots fit comfortably. Without it they're only compressed, and a
344
- full-resolution camera photo can still exceed 1.5 MB and is then refused with
345
- a message. A message can be attachments only,
357
+ on the longest edge and saved as 70% JPEG — typically 200–500 KB, so they
358
+ upload quickly and fit the 1.5 MB cap of an older Workstation. Without it
359
+ they're only compressed. A message can be attachments only,
346
360
  with no text. If a send fails, the attachments stay in the composer so the
347
361
  visitor can retry.
348
362
 
@@ -351,6 +365,19 @@ full-screen viewer right away, from the copy on the phone. A PDF or other file
351
365
  opens in the phone's viewer once Workstation has stored it, usually a moment
352
366
  after it's sent.
353
367
 
368
+ *1.4.0+.* When a message has several photos, sent or received, they show
369
+ together as an album in one square, WhatsApp-style, with "+N" on the last
370
+ tile when there are more than four. Tapping one opens them all in the
371
+ viewer: swipe sideways to move between them (the direction follows the
372
+ app's language), and the header shows which one is open. To show each photo
373
+ on its own, full width, as before, pass `photoAlbums: false`:
374
+
375
+ ```ts
376
+ await RepzoWidget.boot({ channelAccountId: '…', photoAlbums: false });
377
+ ```
378
+
379
+ Tapping a photo still opens the message's photos in the viewer.
380
+
354
381
  ### Your own picker
355
382
 
356
383
  Apps that use `react-native-image-picker`, `react-native-document-picker` or
@@ -434,6 +461,63 @@ get a plain message and no Retry.
434
461
  Expired or revoked tokens (401) are handled silently: the SDK re-inits once
435
462
  (shared by all concurrent requests) and retries.
436
463
 
464
+ *1.5.0+.* When Workstation answers that it's busy (HTTP 429), the error's
465
+ `retryAfter` holds the seconds it asked to wait, when it said.
466
+
467
+ ## Custom attributes
468
+
469
+ `updateUser()` saves attributes on the visitor so agents see them on the
470
+ conversation, for example a plan, a role or which part of your app they're in:
471
+
472
+ ```ts
473
+ await RepzoWidget.updateUser({ plan: 'pro', role: 'manager' });
474
+ ```
475
+
476
+ Up to 30 keys, keys up to 64 characters, values (text, numbers or true/false)
477
+ up to 512 characters. Each call only changes the keys it sends; the others
478
+ keep their saved values.
479
+
480
+ *1.5.0+.* Calls are paced so an app can't flood Workstation:
481
+
482
+ - Values Workstation already has aren't sent again.
483
+ - Up to 20 updates go out at once, then one every 90 seconds (20 per 30
484
+ minutes).
485
+ - Calls in between are merged, newest value per key, and sent on the next
486
+ allowed update. They resolve right away. The latest value of every
487
+ attribute still arrives.
488
+ - When Workstation answers that it's busy, the SDK waits as long as it asks
489
+ and sends again. After a failed connection or a server error it tries again
490
+ a minute later, and the call that failed still rejects.
491
+
492
+ Send what agents need to see, not live data. A value that changes every few
493
+ seconds, such as a GPS position, only arrives about every 90 seconds once the
494
+ first 20 updates are used. `boot({ throttleUpdateUser: false })` sends every
495
+ call straight away, as before 1.5.0.
496
+
497
+ ## Conversation list
498
+
499
+ *1.4.0+.* On Home and in Messages, each conversation shows its newest
500
+ message. When the visitor sent it, "You: " comes first (Arabic "أنت: "), and a
501
+ message with no words shows as "Photo" or "File" (Arabic "صورة", "ملف").
502
+ Workspaces can change these with the `lastMessageYou` (keeps `{message}`),
503
+ `lastMessagePhoto` and `lastMessageFile` translation keys.
504
+
505
+ Workstation's list doesn't say who sent the newest message, so the SDK
506
+ remembers it from what this device sent, received or loaded, across launches
507
+ too. A conversation last written in on another device shows no "You:". To
508
+ show the message without "You:", as before 1.4.0:
509
+
510
+ ```ts
511
+ await RepzoWidget.boot({ channelAccountId: '…', youPrefix: false });
512
+ ```
513
+
514
+ Conversations in the `MessageDidArrive` payload carry the same information in
515
+ two optional fields: `lastMessageFromVisitor` (`true` when the visitor sent
516
+ the newest message) and `lastMessageAttachment` (`'photo'` or `'file'` when it
517
+ had no words, otherwise `null`). `snippet` stays text, worded the way
518
+ Workstation's list words it ("2 attachments"). Both fields are filled in
519
+ whether or not `youPrefix` is on.
520
+
437
521
  ## In-app notifications
438
522
 
439
523
  When a team reply arrives while the app is open but the messenger is closed,
@@ -686,7 +770,7 @@ sub.remove();
686
770
  | Event | Payload | Use it for |
687
771
  | --- | --- | --- |
688
772
  | `UnreadCountDidChange` | `{ count }` | Badge on your own chat button. `count` is the number of unread team replies across all conversations; it drops only when the visitor opens a thread (opening the messenger alone doesn't clear it). Counts are server-backed, so replies that arrived while the app was killed are included after a cold start. |
689
- | `MessageDidArrive` | `{ conversationId, message, conversation }` | Your own in-app banner while the app is foregrounded (no push is sent then). Fires only for team replies that count as unread — never the visitor's echo, never system rows like "agent joined", never the thread already open. |
773
+ | `MessageDidArrive` | `{ conversationId, message, conversation }` | Your own in-app banner while the app is foregrounded (no push is sent then). Fires only for team replies that count as unread — never the visitor's echo, never system rows like "agent joined", never the thread already open. *1.4.0+:* `conversation.lastMessageFromVisitor` and `conversation.lastMessageAttachment` say who sent the newest message and whether it was only photos or files (see "Conversation list"). |
690
774
  | `ConversationDidRead` | `{ conversationId }` | Dismissing that conversation's push notification (example above). |
691
775
  | `WindowDidShow` / `WindowDidHide` | — | Pause your own UI (tours, toasts) while the messenger is open. |
692
776
 
@@ -694,10 +778,10 @@ sub.remove();
694
778
 
695
779
  | Method | Notes |
696
780
  | --- | --- |
697
- | `boot({ channelAccountId, baseUrl?, user?, push?, onOpenUrl?, attachments?, inAppNotifications?, posts? })` | Mints/resumes the session and connects the live stream. Idempotent; safe to call repeatedly. |
781
+ | `boot({ channelAccountId, baseUrl?, user?, push?, onOpenUrl?, attachments?, inAppNotifications?, posts?, photoAlbums?, youPrefix?, throttleUpdateUser? })` | Mints/resumes the session and connects the live stream. Idempotent; safe to call repeatedly. |
698
782
  | `loginUserWithUserAttributes({ userId?, email?, name?, phone?, userHash?, account?, signedUpAt? })` | Identifies the visitor; moves anonymous history over. `account: { id, name?, website? }` links them to a CRM account matched by `id`; `signedUpAt` (Date, unix seconds or ISO) shows as "Signed up". A different stored user is signed out first. |
699
783
  | `loginUnidentifiedUser()` | Drops the identity (signing out a stored user), fresh anonymous session. |
700
- | `updateUser(attributes)` | Custom attributes agents see on the conversation (≤30 keys, 64-char keys, 512-char values). |
784
+ | `updateUser(attributes)` | Custom attributes agents see on the conversation (≤30 keys, 64-char keys, 512-char values). *1.5.0+:* paced, see "Custom attributes". |
701
785
  | `logout()` | Revokes the token, removes the push registration, wipes visitor data, rotates the device id, resets the UI. Await it on sign-out. |
702
786
  | `present()` / `presentSpace('home' \| 'messages')` / `presentConversation(id)` / `hide()` | UI commands. |
703
787
  | `getUnreadConversationCount()` | Number of conversations with at least one unread reply. |
@@ -740,6 +824,8 @@ action from you:
740
824
  | 1.0.9 or older | Nothing required. Development builds now warn once if the visitor token isn't in secure storage, or if the device has no secure random source — see "Secure token storage" and "Requirements". |
741
825
  | 1.1.0 or older | Posts from Workstation now show over the app by default, once one is set live for Mobile (`posts: false` turns them off). They need `<RepzoWidgetProvider>` mounted at the root. |
742
826
  | 1.2.2 or older | Nothing required. In apps with Expo modules, the SDK now includes a small native voice player, so build the app again (development or store build) to get it; `expo-audio` can be removed if nothing else in your app uses it. If your `runtimeVersion` uses the `fingerprint` policy, it changes with this update. |
827
+ | 1.3.1 or older | Nothing required. With a Workstation that takes uploads, a picked file now starts uploading right away, before the visitor taps Send (`attachments: { uploadOnPick: false }` uploads on Send instead). Several photos in one message now show as an album (`photoAlbums: false` shows each on its own, as before). Home and Messages put "You:" before the visitor's own last message (`youPrefix: false` turns it off). |
828
+ | 1.4.0 or older | Nothing required. `updateUser()` is now paced: unchanged values are skipped, and past 20 updates in 30 minutes calls are merged and sent later (`throttleUpdateUser: false` sends every call, as before). See "Custom attributes". |
743
829
 
744
830
  ## Troubleshooting
745
831
 
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.WidgetApiClient = exports.RepzoWidgetError = void 0;
7
7
  exports.isTerminalErrorCode = isTerminalErrorCode;
8
+ var _fileSize = require("../attachments/file-size.js");
8
9
  var _storage = require("../storage.js");
9
10
  var _safeUrl = require("../ui/utils/safe-url.js");
10
11
  /**
@@ -23,24 +24,91 @@ function isTerminalErrorCode(code) {
23
24
  return !!code && TERMINAL_CODES.has(code);
24
25
  }
25
26
  class RepzoWidgetError extends Error {
26
- constructor(message, status, code = 'http') {
27
+ /**
28
+ * Seconds Workstation asked to wait before trying again, on a 429
29
+ * (`Retry-After`). Undefined when it didn't say.
30
+ * @since 1.5.0
31
+ */
32
+
33
+ constructor(message, status, code = 'http', retryAfter) {
27
34
  super(message);
28
35
  this.name = 'RepzoWidgetError';
29
36
  this.status = status;
30
37
  this.code = code;
38
+ if (retryAfter !== undefined) this.retryAfter = retryAfter;
31
39
  }
32
40
 
33
41
  /** Builds the error for a non-2xx `{ message, code }` body. */
34
- static fromResponse(status, body) {
42
+ static fromResponse(status, body, retryAfterHeader) {
35
43
  const payload = body && typeof body === 'object' ? body : {};
36
44
  const message = typeof payload.message === 'string' && payload.message ? payload.message : null;
37
45
  const code = status === 403 && typeof payload.code === 'string' && isTerminalErrorCode(payload.code) ? payload.code : 'http';
38
- return new RepzoWidgetError(message || `HTTP ${status}`, status, code);
46
+ const retryAfter = status === 429 ? parseRetryAfter(retryAfterHeader, payload.retryAfter) : undefined;
47
+ return new RepzoWidgetError(message || `HTTP ${status}`, status, code, retryAfter);
39
48
  }
40
49
  }
41
50
 
42
- /** A Send message part asked for on its own, which may share one request with others. */
51
+ /**
52
+ * Seconds from a `Retry-After` header (seconds or an HTTP date), falling back
53
+ * to the `retryAfter` seconds Workstation also puts in the 429 body.
54
+ */
43
55
  exports.RepzoWidgetError = RepzoWidgetError;
56
+ function parseRetryAfter(header, bodyValue) {
57
+ const value = header?.trim();
58
+ if (value) {
59
+ if (/^\d+(\.\d+)?$/.test(value)) return Number(value);
60
+ const at = Date.parse(value);
61
+ if (Number.isFinite(at)) return Math.max(0, (at - Date.now()) / 1000);
62
+ }
63
+ if (typeof bodyValue === 'number' && Number.isFinite(bodyValue) && bodyValue >= 0) return bodyValue;
64
+ return undefined;
65
+ }
66
+
67
+ /** A Send message part asked for on its own, which may share one request with others. */
68
+
69
+ /**
70
+ * Sends a local file as the request body straight from disk and resolves the
71
+ * HTTP status. React Native streams a `{ uri }` body natively; `fetch` would
72
+ * stringify it, so this goes through XMLHttpRequest.
73
+ */
74
+ function sendFileFromDisk(url, method, headers, file, signal) {
75
+ return new Promise((resolve, reject) => {
76
+ const xhr = new XMLHttpRequest();
77
+ const cancel = () => xhr.abort();
78
+ const settle = finish => {
79
+ signal?.removeEventListener('abort', cancel);
80
+ finish();
81
+ };
82
+ xhr.onload = () => settle(() => resolve(xhr.status));
83
+ xhr.onerror = () => settle(() => reject(new RepzoWidgetError('Network request failed', 0, 'network')));
84
+ xhr.ontimeout = xhr.onerror;
85
+ xhr.onabort = () => settle(() => reject(new RepzoWidgetError('Upload cancelled', 0, 'network')));
86
+ xhr.open(method, url);
87
+ Object.entries(headers).forEach(([name, value]) => xhr.setRequestHeader(name, value));
88
+ // Android refuses a `{ uri }` body without one.
89
+ if (!Object.keys(headers).some(name => name.toLowerCase() === 'content-type')) {
90
+ xhr.setRequestHeader('Content-Type', file.contentType);
91
+ }
92
+ signal?.addEventListener('abort', cancel);
93
+ xhr.send({
94
+ uri: file.uri
95
+ });
96
+ });
97
+ }
98
+
99
+ /**
100
+ * How long Workstation keeps an upload for a message to name, when its answer
101
+ * has no usable `expiresAt` (deployments without it, or a phone clock that
102
+ * reads it as already past).
103
+ */
104
+ const UPLOAD_LIFETIME_MS = 60 * 60 * 1000;
105
+
106
+ /** A file in storage, waiting for the message that names it. */
107
+
108
+ function uploadExpiry(expiresAt, now) {
109
+ const at = typeof expiresAt === 'string' ? Date.parse(expiresAt) : typeof expiresAt === 'number' ? expiresAt : NaN;
110
+ return at > now ? at : now + UPLOAD_LIFETIME_MS;
111
+ }
44
112
  class WidgetApiClient {
45
113
  baseUrl = '';
46
114
  channelAccountId = '';
@@ -128,7 +196,7 @@ class WidgetApiClient {
128
196
  }
129
197
  if (!response.ok) {
130
198
  const body = await response.json().catch(() => null);
131
- const error = RepzoWidgetError.fromResponse(response.status, body);
199
+ const error = RepzoWidgetError.fromResponse(response.status, body, response.headers?.get?.('Retry-After'));
132
200
  // skipReauth calls (init, logout-time) are handled by their caller,
133
201
  // which knows whether the result is still current.
134
202
  if (isTerminalErrorCode(error.code) && !options.skipReauth) this.onTerminalError?.(error);
@@ -282,41 +350,70 @@ class WidgetApiClient {
282
350
  /**
283
351
  * Puts a local file in storage ahead of the message, which then names it by
284
352
  * the returned upload token. The target is presigned storage, or a path on
285
- * the server where its storage can't presign. The file is read as a native
286
- * blob, so it never becomes a JS string; its size is the blob's, since the
287
- * upload URL is signed for exactly the size asked for.
353
+ * the server where its storage can't presign. The upload URL is signed for
354
+ * exactly the size asked for: a `file://` file whose size `expo-file-system`
355
+ * can read streams from disk, anything else is read as a native blob and
356
+ * measured. `signal` cancels the transfer (the visitor removed the file, or
357
+ * left the conversation). Resolves the token and when it expires.
288
358
  */
289
- async uploadAttachment(file) {
290
- let blob;
291
- try {
292
- blob = await (await fetch(file.uri)).blob();
293
- } catch (error) {
294
- throw new RepzoWidgetError(error instanceof Error ? error.message : 'Could not read the file', 0, 'network');
359
+ async uploadAttachment(file, signal) {
360
+ // A file removed early is never read, nor given a slot in storage.
361
+ const stopIfCancelled = () => {
362
+ if (signal?.aborted) throw new RepzoWidgetError('Upload cancelled', 0, 'network');
363
+ };
364
+ stopIfCancelled();
365
+ // Streaming matters on Android: React Native also copies a blob it reads
366
+ // into a Base64 string for its network inspector, so a file past ~30 MB
367
+ // runs the app out of memory. Only file:// gives an exact Content-Length.
368
+ const streamedSize = (0, _safeUrl.urlScheme)(file.uri) === 'file:' ? await (0, _fileSize.measureFileSize)(file.uri) : null;
369
+ let blob = null;
370
+ if (streamedSize === null) {
371
+ try {
372
+ blob = await (await fetch(file.uri, {
373
+ signal
374
+ })).blob();
375
+ } catch (error) {
376
+ throw new RepzoWidgetError(error instanceof Error ? error.message : 'Could not read the file', 0, 'network');
377
+ }
295
378
  }
379
+ stopIfCancelled();
296
380
  const target = await this.request('/api/widget/uploads', {
297
381
  method: 'POST',
298
382
  body: {
299
383
  fileName: file.name,
300
384
  contentType: file.contentType,
301
- size: blob.size || file.size
385
+ size: streamedSize ?? (blob?.size || file.size)
302
386
  }
303
387
  });
388
+ // Counted from when the token was issued, not from when the file is up.
389
+ const expiresAt = uploadExpiry(target.expiresAt, Date.now());
304
390
  const uploadUrl = this.resolveUrl(target.uploadUrl);
305
391
  if (!uploadUrl) throw new RepzoWidgetError('Invalid upload target', 0, 'http');
306
- let response;
307
- try {
308
- response = await fetch(uploadUrl, {
309
- method: target.method ?? 'PUT',
310
- headers: target.headers ?? {
311
- 'Content-Type': file.contentType
312
- },
313
- body: blob
314
- });
315
- } catch (error) {
316
- throw new RepzoWidgetError(error instanceof Error ? error.message : 'Upload failed', 0, 'network');
392
+ stopIfCancelled();
393
+ const method = target.method ?? 'PUT';
394
+ const headers = target.headers ?? {
395
+ 'Content-Type': file.contentType
396
+ };
397
+ let status;
398
+ if (blob) {
399
+ try {
400
+ status = (await fetch(uploadUrl, {
401
+ method,
402
+ headers,
403
+ body: blob,
404
+ signal
405
+ })).status;
406
+ } catch (error) {
407
+ throw new RepzoWidgetError(error instanceof Error ? error.message : 'Upload failed', 0, 'network');
408
+ }
409
+ } else {
410
+ status = await sendFileFromDisk(uploadUrl, method, headers, file, signal);
317
411
  }
318
- if (!response.ok) throw new RepzoWidgetError('Upload failed', response.status, 'http');
319
- return target.uploadToken;
412
+ if (status < 200 || status >= 300) throw new RepzoWidgetError('Upload failed', status, 'http');
413
+ return {
414
+ uploadToken: target.uploadToken,
415
+ expiresAt
416
+ };
320
417
  }
321
418
  listConversations() {
322
419
  return this.request('/api/widget/conversations');