@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/src/api/client.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * The token itself lives in `SecureStorage`.
9
9
  */
10
10
 
11
+ import { measureFileSize } from '../attachments/file-size';
11
12
  import { SecureStorage, TOKEN_KEY } from '../storage';
12
13
  import type {
13
14
  AgentUiAction,
@@ -40,26 +41,51 @@ export class RepzoWidgetError extends Error {
40
41
 
41
42
  readonly code: RepzoWidgetErrorCode;
42
43
 
43
- constructor(message: string, status: number, code: RepzoWidgetErrorCode = 'http') {
44
+ /**
45
+ * Seconds Workstation asked to wait before trying again, on a 429
46
+ * (`Retry-After`). Undefined when it didn't say.
47
+ * @since 1.5.0
48
+ */
49
+ readonly retryAfter?: number;
50
+
51
+ constructor(message: string, status: number, code: RepzoWidgetErrorCode = 'http', retryAfter?: number) {
44
52
  super(message);
45
53
  this.name = 'RepzoWidgetError';
46
54
  this.status = status;
47
55
  this.code = code;
56
+ if (retryAfter !== undefined) this.retryAfter = retryAfter;
48
57
  }
49
58
 
50
59
  /** Builds the error for a non-2xx `{ message, code }` body. */
51
- static fromResponse(status: number, body: unknown): RepzoWidgetError {
60
+ static fromResponse(status: number, body: unknown, retryAfterHeader?: string | null): RepzoWidgetError {
52
61
  const payload = (body && typeof body === 'object' ? body : {}) as {
53
62
  message?: unknown;
54
63
  code?: unknown;
64
+ retryAfter?: unknown;
55
65
  };
56
66
  const message = typeof payload.message === 'string' && payload.message ? payload.message : null;
57
67
  const code =
58
68
  status === 403 && typeof payload.code === 'string' && isTerminalErrorCode(payload.code)
59
69
  ? (payload.code as RepzoWidgetErrorCode)
60
70
  : 'http';
61
- return new RepzoWidgetError(message || `HTTP ${status}`, status, code);
71
+ const retryAfter = status === 429 ? parseRetryAfter(retryAfterHeader, payload.retryAfter) : undefined;
72
+ return new RepzoWidgetError(message || `HTTP ${status}`, status, code, retryAfter);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Seconds from a `Retry-After` header (seconds or an HTTP date), falling back
78
+ * to the `retryAfter` seconds Workstation also puts in the 429 body.
79
+ */
80
+ function parseRetryAfter(header: string | null | undefined, bodyValue: unknown): number | undefined {
81
+ const value = header?.trim();
82
+ if (value) {
83
+ if (/^\d+(\.\d+)?$/.test(value)) return Number(value);
84
+ const at = Date.parse(value);
85
+ if (Number.isFinite(at)) return Math.max(0, (at - Date.now()) / 1000);
62
86
  }
87
+ if (typeof bodyValue === 'number' && Number.isFinite(bodyValue) && bodyValue >= 0) return bodyValue;
88
+ return undefined;
63
89
  }
64
90
 
65
91
  /** A Send message part asked for on its own, which may share one request with others. */
@@ -84,6 +110,60 @@ interface RequestOptions {
84
110
  authToken?: string | null;
85
111
  }
86
112
 
113
+ /**
114
+ * Sends a local file as the request body straight from disk and resolves the
115
+ * HTTP status. React Native streams a `{ uri }` body natively; `fetch` would
116
+ * stringify it, so this goes through XMLHttpRequest.
117
+ */
118
+ function sendFileFromDisk(
119
+ url: string,
120
+ method: string,
121
+ headers: Record<string, string>,
122
+ file: { uri: string; contentType: string },
123
+ signal?: AbortSignal
124
+ ): Promise<number> {
125
+ return new Promise((resolve, reject) => {
126
+ const xhr = new XMLHttpRequest();
127
+ const cancel = () => xhr.abort();
128
+ const settle = (finish: () => void) => {
129
+ signal?.removeEventListener('abort', cancel);
130
+ finish();
131
+ };
132
+ xhr.onload = () => settle(() => resolve(xhr.status));
133
+ xhr.onerror = () => settle(() => reject(new RepzoWidgetError('Network request failed', 0, 'network')));
134
+ xhr.ontimeout = xhr.onerror;
135
+ xhr.onabort = () => settle(() => reject(new RepzoWidgetError('Upload cancelled', 0, 'network')));
136
+ xhr.open(method, url);
137
+ Object.entries(headers).forEach(([name, value]) => xhr.setRequestHeader(name, value));
138
+ // Android refuses a `{ uri }` body without one.
139
+ if (!Object.keys(headers).some((name) => name.toLowerCase() === 'content-type')) {
140
+ xhr.setRequestHeader('Content-Type', file.contentType);
141
+ }
142
+ signal?.addEventListener('abort', cancel);
143
+ xhr.send({ uri: file.uri } as unknown as XMLHttpRequestBodyInit);
144
+ });
145
+ }
146
+
147
+ /**
148
+ * How long Workstation keeps an upload for a message to name, when its answer
149
+ * has no usable `expiresAt` (deployments without it, or a phone clock that
150
+ * reads it as already past).
151
+ */
152
+ const UPLOAD_LIFETIME_MS = 60 * 60 * 1000;
153
+
154
+ /** A file in storage, waiting for the message that names it. */
155
+ export interface UploadedFile {
156
+ uploadToken: string;
157
+ /** When Workstation stops taking `uploadToken`, on this phone's clock (ms). */
158
+ expiresAt: number;
159
+ }
160
+
161
+ function uploadExpiry(expiresAt: unknown, now: number): number {
162
+ const at =
163
+ typeof expiresAt === 'string' ? Date.parse(expiresAt) : typeof expiresAt === 'number' ? expiresAt : NaN;
164
+ return at > now ? at : now + UPLOAD_LIFETIME_MS;
165
+ }
166
+
87
167
  export class WidgetApiClient {
88
168
  private baseUrl = '';
89
169
 
@@ -187,7 +267,11 @@ export class WidgetApiClient {
187
267
 
188
268
  if (!response.ok) {
189
269
  const body = await response.json().catch(() => null);
190
- const error = RepzoWidgetError.fromResponse(response.status, body);
270
+ const error = RepzoWidgetError.fromResponse(
271
+ response.status,
272
+ body,
273
+ response.headers?.get?.('Retry-After')
274
+ );
191
275
  // skipReauth calls (init, logout-time) are handled by their caller,
192
276
  // which knows whether the result is still current.
193
277
  if (isTerminalErrorCode(error.code) && !options.skipReauth) this.onTerminalError?.(error);
@@ -360,54 +444,81 @@ export class WidgetApiClient {
360
444
  /**
361
445
  * Puts a local file in storage ahead of the message, which then names it by
362
446
  * the returned upload token. The target is presigned storage, or a path on
363
- * the server where its storage can't presign. The file is read as a native
364
- * blob, so it never becomes a JS string; its size is the blob's, since the
365
- * upload URL is signed for exactly the size asked for.
447
+ * the server where its storage can't presign. The upload URL is signed for
448
+ * exactly the size asked for: a `file://` file whose size `expo-file-system`
449
+ * can read streams from disk, anything else is read as a native blob and
450
+ * measured. `signal` cancels the transfer (the visitor removed the file, or
451
+ * left the conversation). Resolves the token and when it expires.
366
452
  */
367
- async uploadAttachment(file: {
368
- uri: string;
369
- name: string;
370
- contentType: string;
371
- size: number;
372
- }): Promise<string> {
373
- let blob: Blob;
374
- try {
375
- blob = await (await fetch(file.uri)).blob();
376
- } catch (error) {
377
- throw new RepzoWidgetError(
378
- error instanceof Error ? error.message : 'Could not read the file',
379
- 0,
380
- 'network'
381
- );
453
+ async uploadAttachment(
454
+ file: {
455
+ uri: string;
456
+ name: string;
457
+ contentType: string;
458
+ size: number;
459
+ },
460
+ signal?: AbortSignal
461
+ ): Promise<UploadedFile> {
462
+ // A file removed early is never read, nor given a slot in storage.
463
+ const stopIfCancelled = () => {
464
+ if (signal?.aborted) throw new RepzoWidgetError('Upload cancelled', 0, 'network');
465
+ };
466
+ stopIfCancelled();
467
+ // Streaming matters on Android: React Native also copies a blob it reads
468
+ // into a Base64 string for its network inspector, so a file past ~30 MB
469
+ // runs the app out of memory. Only file:// gives an exact Content-Length.
470
+ const streamedSize = urlScheme(file.uri) === 'file:' ? await measureFileSize(file.uri) : null;
471
+ let blob: Blob | null = null;
472
+ if (streamedSize === null) {
473
+ try {
474
+ blob = await (await fetch(file.uri, { signal })).blob();
475
+ } catch (error) {
476
+ throw new RepzoWidgetError(
477
+ error instanceof Error ? error.message : 'Could not read the file',
478
+ 0,
479
+ 'network'
480
+ );
481
+ }
382
482
  }
483
+ stopIfCancelled();
383
484
  const target = await this.request<{
384
485
  uploadUrl: string;
385
486
  method?: string;
386
487
  headers?: Record<string, string>;
387
488
  uploadToken: string;
489
+ expiresAt?: string | number;
388
490
  }>('/api/widget/uploads', {
389
491
  method: 'POST',
390
- body: { fileName: file.name, contentType: file.contentType, size: blob.size || file.size },
492
+ body: {
493
+ fileName: file.name,
494
+ contentType: file.contentType,
495
+ size: streamedSize ?? (blob?.size || file.size),
496
+ },
391
497
  });
498
+ // Counted from when the token was issued, not from when the file is up.
499
+ const expiresAt = uploadExpiry(target.expiresAt, Date.now());
392
500
  const uploadUrl = this.resolveUrl(target.uploadUrl);
393
501
  if (!uploadUrl) throw new RepzoWidgetError('Invalid upload target', 0, 'http');
502
+ stopIfCancelled();
394
503
 
395
- let response: Response;
396
- try {
397
- response = await fetch(uploadUrl, {
398
- method: target.method ?? 'PUT',
399
- headers: target.headers ?? { 'Content-Type': file.contentType },
400
- body: blob,
401
- });
402
- } catch (error) {
403
- throw new RepzoWidgetError(
404
- error instanceof Error ? error.message : 'Upload failed',
405
- 0,
406
- 'network'
407
- );
504
+ const method = target.method ?? 'PUT';
505
+ const headers = target.headers ?? { 'Content-Type': file.contentType };
506
+ let status: number;
507
+ if (blob) {
508
+ try {
509
+ status = (await fetch(uploadUrl, { method, headers, body: blob, signal })).status;
510
+ } catch (error) {
511
+ throw new RepzoWidgetError(
512
+ error instanceof Error ? error.message : 'Upload failed',
513
+ 0,
514
+ 'network'
515
+ );
516
+ }
517
+ } else {
518
+ status = await sendFileFromDisk(uploadUrl, method, headers, file, signal);
408
519
  }
409
- if (!response.ok) throw new RepzoWidgetError('Upload failed', response.status, 'http');
410
- return target.uploadToken;
520
+ if (status < 200 || status >= 300) throw new RepzoWidgetError('Upload failed', status, 'http');
521
+ return { uploadToken: target.uploadToken, expiresAt };
411
522
  }
412
523
 
413
524
  listConversations(): Promise<{ data: Conversation[] }> {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Local file sizes from the host's `expo-file-system` (optional, like the
3
+ * pickers): read from the file's metadata, never from its contents. Shared by
4
+ * the pickers and the upload client, so the client doesn't pull in pickers.
5
+ *
6
+ * No `react-native` import on purpose: it stays testable in plain Node.
7
+ */
8
+
9
+ let fileSystem: any = null;
10
+ try {
11
+ fileSystem = require('expo-file-system') ?? null;
12
+ } catch {
13
+ fileSystem = null;
14
+ }
15
+
16
+ /** A positive, finite byte count, or null. */
17
+ export function validSize(size: unknown): number | null {
18
+ return typeof size === 'number' && Number.isFinite(size) && size > 0 ? size : null;
19
+ }
20
+
21
+ /**
22
+ * A local file's exact size in bytes, from its metadata without reading it.
23
+ * Null without `expo-file-system`, or when it can't tell.
24
+ */
25
+ export async function measureFileSize(uri: string): Promise<number | null> {
26
+ if (typeof fileSystem?.File === 'function') {
27
+ try {
28
+ const size = validSize(new fileSystem.File(uri).size);
29
+ if (size !== null) return size;
30
+ } catch {
31
+ // Fall through to the next reader.
32
+ }
33
+ }
34
+ if (typeof fileSystem?.getInfoAsync === 'function') {
35
+ try {
36
+ return validSize((await fileSystem.getInfoAsync(uri, { size: true }))?.size);
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+ return null;
42
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The phone's copy of each file the visitor sent, by the URL of the stored
3
+ * copy that replaced it in the thread. Right after a send the stored copy
4
+ * may not load yet (Workstation can still be storing it), so a photo keeps
5
+ * showing from the phone instead of turning into a file row. Kept in memory
6
+ * only: a later session shows the stored copy.
7
+ */
8
+
9
+ const MAX_PREVIEWS = 50;
10
+ const previews = new Map<string, string>();
11
+
12
+ export function rememberLocalPreview(storedUrl: string, localUrl: string): void {
13
+ if (!storedUrl || !localUrl) return;
14
+ previews.delete(storedUrl);
15
+ previews.set(storedUrl, localUrl);
16
+ // Oldest first: a Map iterates in insertion order.
17
+ if (previews.size > MAX_PREVIEWS) previews.delete(previews.keys().next().value as string);
18
+ }
19
+
20
+ export function localPreviewFor(storedUrl: string): string | null {
21
+ return (storedUrl && previews.get(storedUrl)) || null;
22
+ }
23
+
24
+ /** One that failed to load (the OS cleared the picker's cache): every view moves to the stored copy. */
25
+ export function forgetLocalPreview(storedUrl: string): void {
26
+ previews.delete(storedUrl);
27
+ }
28
+
29
+ /** Signing out forgets them: the next visitor on the phone doesn't inherit the paths. */
30
+ export function clearLocalPreviews(): void {
31
+ previews.clear();
32
+ }
@@ -2,7 +2,7 @@
2
2
  * Visitor attachments: picking photos/files for `POST /api/widget/messages`.
3
3
  *
4
4
  * A server that takes uploads names its cap (`maxAttachmentBytes`): a picked
5
- * file then stays a local URI and is uploaded when the message is sent. An
5
+ * file then stays a local URI, which the composer uploads right away. An
6
6
  * older server only takes files inline, as base64 data URLs within the
7
7
  * 1.5 MB / 6 MB caps below.
8
8
  *
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import type { AttachmentSource, RepzoPickedFile, RepzoWidgetAttachmentOptions } from '../types';
27
+ import { measureFileSize, validSize } from './file-size';
27
28
 
28
29
  /** The server keeps the first five and silently drops the rest. */
29
30
  export const MAX_ATTACHMENTS = 5;
@@ -48,10 +49,19 @@ export interface DraftAttachment {
48
49
  size: number;
49
50
  /** `data:<mime>;base64,<…>` for an inline file; empty when `fileUri` is uploaded instead. */
50
51
  dataUrl: string;
51
- /** The local file uploaded when the message is sent. */
52
+ /** The local file uploaded ahead of the message. */
52
53
  fileUri?: string;
53
54
  /** What `<Image>` renders locally: the picked file's URI, else the data URL. */
54
55
  previewUri: string;
56
+ /**
57
+ * Where `fileUri`'s upload stands. The composer starts it as soon as the
58
+ * file is picked and holds Send until every file is up. Absent for inline files.
59
+ */
60
+ uploadStatus?: 'uploading' | 'uploaded' | 'failed';
61
+ /** What the message names the uploaded file by, once `uploadStatus` is `uploaded`. */
62
+ uploadToken?: string;
63
+ /** When `uploadToken` stops working, on this phone's clock (ms). */
64
+ uploadExpiresAt?: number;
55
65
  }
56
66
 
57
67
  /** Why some (or all) of a pick didn't make it into the composer. */
@@ -119,6 +129,11 @@ export function setAttachmentOptions(options: RepzoWidgetAttachmentOptions | nul
119
129
  hostOptions = options;
120
130
  }
121
131
 
132
+ /** Whether a picked file starts uploading before Send (`attachments.uploadOnPick`). */
133
+ export function uploadsOnPick(): boolean {
134
+ return hostOptions?.uploadOnPick !== false;
135
+ }
136
+
122
137
  const ALL_SOURCES: AttachmentSource[] = ['photo', 'camera', 'file'];
123
138
 
124
139
  /**
@@ -356,7 +371,7 @@ async function toDraft(file: RepzoPickedFile): Promise<DraftAttachment> {
356
371
  };
357
372
  }
358
373
 
359
- /** A file that's uploaded when the message is sent: only its size is read here. */
374
+ /** A file that's uploaded on its own, ahead of the message: only its size is read here. */
360
375
  async function toUploadDraft(file: RepzoPickedFile, uploadLimit: number): Promise<DraftAttachment> {
361
376
  const uri = file.uri!;
362
377
  const size = validSize(file.size) ?? (await readFileSize(uri));
@@ -375,23 +390,9 @@ async function toUploadDraft(file: RepzoPickedFile, uploadLimit: number): Promis
375
390
 
376
391
  /** A local file's size in bytes, or 0 when nothing can tell. */
377
392
  async function readFileSize(uri: string): Promise<number> {
378
- if (typeof fileSystem?.File === 'function') {
379
- try {
380
- const size = validSize(new fileSystem.File(uri).size);
381
- if (size !== null) return size;
382
- } catch {
383
- // Fall through to the next reader.
384
- }
385
- }
386
- if (typeof fileSystem?.getInfoAsync === 'function') {
387
- try {
388
- const size = validSize((await fileSystem.getInfoAsync(uri, { size: true }))?.size);
389
- if (size !== null) return size;
390
- } catch {
391
- // Fall through to fetch.
392
- }
393
- }
394
- // A native blob: its size is known without reading it into JS.
393
+ const size = await measureFileSize(uri);
394
+ if (size !== null) return size;
395
+ // Last resort: the native blob reads the whole file (on Android, into memory).
395
396
  const blob = await (await fetch(uri)).blob();
396
397
  return validSize(blob.size) ?? 0;
397
398
  }
@@ -524,10 +525,6 @@ export function isImageAttachment(contentType: string | null | undefined): boole
524
525
  return !!contentType && contentType.toLowerCase().startsWith('image/');
525
526
  }
526
527
 
527
- function validSize(size: unknown): number | null {
528
- return typeof size === 'number' && Number.isFinite(size) && size > 0 ? size : null;
529
- }
530
-
531
528
  function fileNameFromUri(uri: string | undefined): string | null {
532
529
  if (!uri) return null;
533
530
  const last = uri.split(/[?#]/)[0]?.split('/').pop();
@@ -563,7 +560,8 @@ const CONTENT_TYPES: Record<string, string> = {
563
560
  mov: 'video/quicktime',
564
561
  };
565
562
 
566
- function guessContentType(nameOrUri: string | null | undefined): string | null {
563
+ /** The type a file name's extension implies, or null for one this table doesn't know. */
564
+ export function guessContentType(nameOrUri: string | null | undefined): string | null {
567
565
  const extension = /\.([a-z0-9]+)$/i.exec(nameOrUri?.split(/[?#]/)[0] ?? '')?.[1];
568
566
  return extension ? (CONTENT_TYPES[extension.toLowerCase()] ?? null) : null;
569
567
  }
package/src/i18n.ts CHANGED
@@ -59,6 +59,14 @@ export const DEFAULT_LABELS = {
59
59
  loadEarlier: 'Load earlier messages',
60
60
  emptyConversation: 'Send a message to start the conversation',
61
61
  messagePlaceholder: 'Type a message...',
62
+ // Screen-reader label of the send button beside the message field.
63
+ sendMessage: 'Send message',
64
+ // Screen-reader labels of the button that returns to the newest message
65
+ // after scrolling up; the second when teammates replied in the meantime.
66
+ jumpToLatest: 'Jump to latest messages',
67
+ jumpToLatestNew: 'New messages: {count}. Jump to latest',
68
+ // Screen readers, on the last tile of a photo album showing "+{count}".
69
+ morePhotos: '{count} more photos',
62
70
  attachFile: 'Attach file',
63
71
  // Attach menu, composer chips and the notices under them.
64
72
  attachPhotoLibrary: 'Photo library',
@@ -70,6 +78,11 @@ export const DEFAULT_LABELS = {
70
78
  attachmentsTooLargeTogether:
71
79
  'Attachments are too large to send together — send some in another message',
72
80
  attachmentFailed: "Couldn't attach that file",
81
+ // A picked file uploads before Send is enabled. Screen-reader labels for its
82
+ // chip while it uploads and after it failed, then the notice for a failure.
83
+ attachmentUploading: 'Uploading {name}',
84
+ attachmentUploadFailed: "Couldn't upload {name}. Tap to try again",
85
+ attachmentsUploadFailed: "Couldn't upload a file. Tap it to try again",
73
86
  // A file in a message; tapping opens it. `{name}` is the file name.
74
87
  openAttachment: 'Open {name}',
75
88
  // Voice notes played in the chat (with expo-audio).
@@ -79,6 +92,8 @@ export const DEFAULT_LABELS = {
79
92
  // Full-screen image viewer.
80
93
  imageLoadFailed: "Couldn't load this image",
81
94
  openImageExternally: 'Open image',
95
+ // Screen readers: which of a message's photos is open.
96
+ imagePosition: '{current} of {total}',
82
97
  cameraPermissionDenied: 'Allow camera access in Settings to take a photo',
83
98
  cancel: 'Cancel',
84
99
  failedToSend: 'Failed to send',
@@ -93,13 +108,17 @@ export const DEFAULT_LABELS = {
93
108
  preChatSubtitle: 'Let us know a bit about you so we can help you better.',
94
109
  preChatSkip: 'Skip',
95
110
  preChatSubmit: 'Start chat',
96
- viewPreviousConversations: 'View previous conversations',
97
- homeRecentTitle: 'Recent',
98
- homeViewAllCount: 'View all ({count})',
111
+ // Heading of Home's card that shows the newest conversation.
112
+ homeRecentMessageTitle: 'Recent message',
99
113
  homeNoConversations: 'Your conversations will show up here',
100
114
  homeStatusOnline: "We're online",
101
115
  homeStatusAway: "We're away",
102
116
  unreadBadge: '{count} unread',
117
+ // A conversation row whose newest message was only photos, or other files.
118
+ lastMessagePhoto: 'Photo',
119
+ lastMessageFile: 'File',
120
+ // Before the visitor's own newest message in a conversation row.
121
+ lastMessageYou: 'You: {message}',
103
122
  team: 'Team',
104
123
  home: 'Home',
105
124
  close: 'Close',
package/src/index.tsx CHANGED
@@ -91,6 +91,11 @@ export type RepzoWidgetType = {
91
91
  /**
92
92
  * Attach custom attributes to the visitor's identity so agents see them on
93
93
  * the conversation (max 30 keys, 64-char keys, 512-char values).
94
+ *
95
+ * Since 1.5.0 calls are paced (see `BootOptions.throttleUpdateUser`):
96
+ * values Workstation already has are skipped, and once more than 20 updates
97
+ * go out in 30 minutes the rest are merged and sent later. Those calls
98
+ * resolve right away. A call sent straight away still rejects when it fails.
94
99
  */
95
100
  updateUser(attributes: WidgetCustomAttributes): Promise<void>;
96
101
 
package/src/locales.ts CHANGED
@@ -20,6 +20,10 @@ const ar: LabelMap = {
20
20
  loadEarlier: 'تحميل الرسائل السابقة',
21
21
  emptyConversation: 'أرسل رسالة لبدء المحادثة',
22
22
  messagePlaceholder: 'اكتب رسالة...',
23
+ sendMessage: 'إرسال الرسالة',
24
+ jumpToLatest: 'الانتقال إلى أحدث الرسائل',
25
+ jumpToLatestNew: 'رسائل جديدة: {count}. الانتقال إلى الأحدث',
26
+ morePhotos: 'صور أخرى: {count}',
23
27
  attachFile: 'إرفاق ملف',
24
28
  attachPhotoLibrary: 'مكتبة الصور',
25
29
  attachTakePhoto: 'التقاط صورة',
@@ -29,12 +33,16 @@ const ar: LabelMap = {
29
33
  attachmentTooMany: 'يمكنك إرفاق {count} ملفات كحد أقصى',
30
34
  attachmentsTooLargeTogether: 'المرفقات كبيرة جدًا لإرسالها معًا — أرسل بعضها في رسالة أخرى',
31
35
  attachmentFailed: 'تعذر إرفاق هذا الملف',
36
+ attachmentUploading: 'جارٍ رفع {name}',
37
+ attachmentUploadFailed: 'تعذر رفع {name}. اضغط لإعادة المحاولة',
38
+ attachmentsUploadFailed: 'تعذر رفع ملف. اضغط عليه لإعادة المحاولة',
32
39
  openAttachment: 'فتح {name}',
33
40
  voicePlay: 'تشغيل الرسالة الصوتية',
34
41
  voicePause: 'إيقاف الرسالة الصوتية مؤقتًا',
35
42
  voiceSpeed: 'سرعة التشغيل',
36
43
  imageLoadFailed: 'تعذر تحميل هذه الصورة',
37
44
  openImageExternally: 'فتح الصورة',
45
+ imagePosition: '{current} من {total}',
38
46
  cameraPermissionDenied: 'اسمح بالوصول إلى الكاميرا من الإعدادات لالتقاط صورة',
39
47
  cancel: 'إلغاء',
40
48
  failedToSend: 'تعذر الإرسال',
@@ -48,13 +56,14 @@ const ar: LabelMap = {
48
56
  preChatSubtitle: 'أخبرنا قليلاً عنك حتى نتمكن من مساعدتك بشكل أفضل.',
49
57
  preChatSkip: 'تخطٍ',
50
58
  preChatSubmit: 'ابدأ المحادثة',
51
- viewPreviousConversations: 'عرض المحادثات السابقة',
52
- homeRecentTitle: 'الأحدث',
53
- homeViewAllCount: 'عرض الكل ({count})',
59
+ homeRecentMessageTitle: 'أحدث رسالة',
54
60
  homeNoConversations: 'ستظهر محادثاتك هنا',
55
61
  homeStatusOnline: 'نحن متصلون',
56
62
  homeStatusAway: 'نحن غير متصلين',
57
63
  unreadBadge: '{count} غير مقروءة',
64
+ lastMessagePhoto: 'صورة',
65
+ lastMessageFile: 'ملف',
66
+ lastMessageYou: 'أنت: {message}',
58
67
  team: 'الفريق',
59
68
  home: 'الرئيسية',
60
69
  close: 'إغلاق',