@harkenapp/sdk-react-native 0.0.1-alpha.1 → 0.0.1-alpha.2

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 (278) hide show
  1. package/README.md +44 -7
  2. package/app.plugin.cjs +12 -17
  3. package/dist/__mocks__/async-storage.d.ts +16 -0
  4. package/dist/__mocks__/async-storage.d.ts.map +1 -0
  5. package/dist/__mocks__/async-storage.js +39 -0
  6. package/dist/__mocks__/async-storage.js.map +1 -0
  7. package/dist/__mocks__/expo-document-picker.d.ts +26 -0
  8. package/dist/__mocks__/expo-document-picker.d.ts.map +1 -0
  9. package/dist/__mocks__/expo-document-picker.js +25 -0
  10. package/dist/__mocks__/expo-document-picker.js.map +1 -0
  11. package/dist/__mocks__/expo-file-system.d.ts +42 -0
  12. package/dist/__mocks__/expo-file-system.d.ts.map +1 -0
  13. package/dist/__mocks__/expo-file-system.js +37 -0
  14. package/dist/__mocks__/expo-file-system.js.map +1 -0
  15. package/dist/__mocks__/expo-image-picker.d.ts +30 -0
  16. package/dist/__mocks__/expo-image-picker.d.ts.map +1 -0
  17. package/dist/__mocks__/expo-image-picker.js +30 -0
  18. package/dist/__mocks__/expo-image-picker.js.map +1 -0
  19. package/dist/__mocks__/expo-secure-store.d.ts +15 -0
  20. package/dist/__mocks__/expo-secure-store.d.ts.map +1 -0
  21. package/dist/__mocks__/expo-secure-store.js +30 -0
  22. package/dist/__mocks__/expo-secure-store.js.map +1 -0
  23. package/dist/__mocks__/react-native.d.ts +73 -0
  24. package/dist/__mocks__/react-native.d.ts.map +1 -0
  25. package/dist/__mocks__/react-native.js +45 -0
  26. package/dist/__mocks__/react-native.js.map +1 -0
  27. package/dist/api/client.d.ts +8 -8
  28. package/dist/api/client.d.ts.map +1 -1
  29. package/dist/api/client.js +17 -19
  30. package/dist/api/client.js.map +1 -1
  31. package/dist/api/client.test.d.ts +2 -0
  32. package/dist/api/client.test.d.ts.map +1 -0
  33. package/dist/api/client.test.js +417 -0
  34. package/dist/api/client.test.js.map +1 -0
  35. package/dist/api/errors.d.ts +3 -3
  36. package/dist/api/errors.d.ts.map +1 -1
  37. package/dist/api/errors.js +3 -3
  38. package/dist/api/errors.js.map +1 -1
  39. package/dist/api/errors.test.d.ts +2 -0
  40. package/dist/api/errors.test.d.ts.map +1 -0
  41. package/dist/api/errors.test.js +155 -0
  42. package/dist/api/errors.test.js.map +1 -0
  43. package/dist/api/index.d.ts +6 -6
  44. package/dist/api/index.d.ts.map +1 -1
  45. package/dist/api/index.js.map +1 -1
  46. package/dist/api/retry.d.ts +1 -1
  47. package/dist/api/retry.d.ts.map +1 -1
  48. package/dist/api/retry.js.map +1 -1
  49. package/dist/api/retry.test.d.ts +2 -0
  50. package/dist/api/retry.test.d.ts.map +1 -0
  51. package/dist/api/retry.test.js +193 -0
  52. package/dist/api/retry.test.js.map +1 -0
  53. package/dist/attachments/FeedbackSheet.d.ts +36 -13
  54. package/dist/attachments/FeedbackSheet.d.ts.map +1 -1
  55. package/dist/attachments/FeedbackSheet.js +50 -30
  56. package/dist/attachments/FeedbackSheet.js.map +1 -1
  57. package/dist/attachments/index.d.ts +2 -2
  58. package/dist/components/AttachmentGrid.d.ts +12 -4
  59. package/dist/components/AttachmentGrid.d.ts.map +1 -1
  60. package/dist/components/AttachmentGrid.js +44 -34
  61. package/dist/components/AttachmentGrid.js.map +1 -1
  62. package/dist/components/AttachmentPicker.d.ts +3 -3
  63. package/dist/components/AttachmentPicker.d.ts.map +1 -1
  64. package/dist/components/AttachmentPicker.js +34 -36
  65. package/dist/components/AttachmentPicker.js.map +1 -1
  66. package/dist/components/AttachmentPreview.d.ts +10 -4
  67. package/dist/components/AttachmentPreview.d.ts.map +1 -1
  68. package/dist/components/AttachmentPreview.js +48 -34
  69. package/dist/components/AttachmentPreview.js.map +1 -1
  70. package/dist/components/CategorySelector.d.ts +3 -3
  71. package/dist/components/CategorySelector.d.ts.map +1 -1
  72. package/dist/components/CategorySelector.js +21 -27
  73. package/dist/components/CategorySelector.js.map +1 -1
  74. package/dist/components/FeedbackForm.d.ts +3 -3
  75. package/dist/components/FeedbackForm.d.ts.map +1 -1
  76. package/dist/components/FeedbackForm.js +7 -8
  77. package/dist/components/FeedbackForm.js.map +1 -1
  78. package/dist/components/FeedbackSheet.d.ts +34 -11
  79. package/dist/components/FeedbackSheet.d.ts.map +1 -1
  80. package/dist/components/FeedbackSheet.js +46 -28
  81. package/dist/components/FeedbackSheet.js.map +1 -1
  82. package/dist/components/ThemedButton.d.ts +16 -5
  83. package/dist/components/ThemedButton.d.ts.map +1 -1
  84. package/dist/components/ThemedButton.js +38 -29
  85. package/dist/components/ThemedButton.js.map +1 -1
  86. package/dist/components/ThemedText.d.ts +3 -3
  87. package/dist/components/ThemedText.d.ts.map +1 -1
  88. package/dist/components/ThemedText.js +1 -1
  89. package/dist/components/ThemedText.js.map +1 -1
  90. package/dist/components/ThemedTextInput.d.ts +11 -2
  91. package/dist/components/ThemedTextInput.d.ts.map +1 -1
  92. package/dist/components/ThemedTextInput.js +19 -9
  93. package/dist/components/ThemedTextInput.js.map +1 -1
  94. package/dist/components/UploadStatusOverlay.d.ts +11 -3
  95. package/dist/components/UploadStatusOverlay.d.ts.map +1 -1
  96. package/dist/components/UploadStatusOverlay.js +59 -76
  97. package/dist/components/UploadStatusOverlay.js.map +1 -1
  98. package/dist/components/index.d.ts +18 -18
  99. package/dist/components/index.d.ts.map +1 -1
  100. package/dist/components/index.js.map +1 -1
  101. package/dist/context/HarkenContext.d.ts +20 -15
  102. package/dist/context/HarkenContext.d.ts.map +1 -1
  103. package/dist/context/HarkenContext.js +20 -17
  104. package/dist/context/HarkenContext.js.map +1 -1
  105. package/dist/context/index.d.ts +2 -2
  106. package/dist/domain/index.d.ts +2 -2
  107. package/dist/domain/index.d.ts.map +1 -1
  108. package/dist/domain/index.js.map +1 -1
  109. package/dist/hooks/index.d.ts +5 -5
  110. package/dist/hooks/useAnonymousId.js +1 -1
  111. package/dist/hooks/useAnonymousId.test.d.ts +2 -0
  112. package/dist/hooks/useAnonymousId.test.d.ts.map +1 -0
  113. package/dist/hooks/useAnonymousId.test.js +154 -0
  114. package/dist/hooks/useAnonymousId.test.js.map +1 -0
  115. package/dist/hooks/useAttachmentPicker.d.ts +3 -3
  116. package/dist/hooks/useAttachmentPicker.js +7 -7
  117. package/dist/hooks/useAttachmentStatus.d.ts +1 -1
  118. package/dist/hooks/useAttachmentStatus.d.ts.map +1 -1
  119. package/dist/hooks/useAttachmentStatus.js.map +1 -1
  120. package/dist/hooks/useAttachmentUpload.d.ts +2 -2
  121. package/dist/hooks/useAttachmentUpload.d.ts.map +1 -1
  122. package/dist/hooks/useAttachmentUpload.js +5 -5
  123. package/dist/hooks/useAttachmentUpload.js.map +1 -1
  124. package/dist/hooks/useAttachmentUpload.test.d.ts +2 -0
  125. package/dist/hooks/useAttachmentUpload.test.d.ts.map +1 -0
  126. package/dist/hooks/useAttachmentUpload.test.js +542 -0
  127. package/dist/hooks/useAttachmentUpload.test.js.map +1 -0
  128. package/dist/hooks/useFeedback.d.ts +4 -4
  129. package/dist/hooks/useFeedback.d.ts.map +1 -1
  130. package/dist/hooks/useFeedback.js +3 -5
  131. package/dist/hooks/useFeedback.js.map +1 -1
  132. package/dist/hooks/useFeedback.test.d.ts +2 -0
  133. package/dist/hooks/useFeedback.test.d.ts.map +1 -0
  134. package/dist/hooks/useFeedback.test.js +299 -0
  135. package/dist/hooks/useFeedback.test.js.map +1 -0
  136. package/dist/hooks/useHarkenContext.d.ts +1 -1
  137. package/dist/hooks/useHarkenContext.js +1 -1
  138. package/dist/hooks/useHarkenTheme.d.ts +27 -3
  139. package/dist/hooks/useHarkenTheme.d.ts.map +1 -1
  140. package/dist/hooks/useHarkenTheme.js +26 -2
  141. package/dist/hooks/useHarkenTheme.js.map +1 -1
  142. package/dist/index.d.ts +28 -28
  143. package/dist/index.d.ts.map +1 -1
  144. package/dist/index.js.map +1 -1
  145. package/dist/services/index.d.ts +3 -3
  146. package/dist/services/index.d.ts.map +1 -1
  147. package/dist/services/index.js.map +1 -1
  148. package/dist/services/uploadQueueService.d.ts +2 -2
  149. package/dist/services/uploadQueueService.d.ts.map +1 -1
  150. package/dist/services/uploadQueueService.js +16 -17
  151. package/dist/services/uploadQueueService.js.map +1 -1
  152. package/dist/services/uploadQueueService.test.d.ts +2 -0
  153. package/dist/services/uploadQueueService.test.d.ts.map +1 -0
  154. package/dist/services/uploadQueueService.test.js +426 -0
  155. package/dist/services/uploadQueueService.test.js.map +1 -0
  156. package/dist/services/uploadQueueStorage.d.ts +1 -1
  157. package/dist/services/uploadQueueStorage.d.ts.map +1 -1
  158. package/dist/services/uploadQueueStorage.js +4 -4
  159. package/dist/services/uploadQueueStorage.js.map +1 -1
  160. package/dist/services/uploadQueueStorage.test.d.ts +2 -0
  161. package/dist/services/uploadQueueStorage.test.d.ts.map +1 -0
  162. package/dist/services/uploadQueueStorage.test.js +200 -0
  163. package/dist/services/uploadQueueStorage.test.js.map +1 -0
  164. package/dist/storage/IdentityStore.d.ts +1 -1
  165. package/dist/storage/IdentityStore.d.ts.map +1 -1
  166. package/dist/storage/IdentityStore.js.map +1 -1
  167. package/dist/storage/IdentityStore.test.d.ts +2 -0
  168. package/dist/storage/IdentityStore.test.d.ts.map +1 -0
  169. package/dist/storage/IdentityStore.test.js +176 -0
  170. package/dist/storage/IdentityStore.test.js.map +1 -0
  171. package/dist/storage/SecureStoreAdapter.d.ts +1 -1
  172. package/dist/storage/SecureStoreAdapter.test.d.ts +2 -0
  173. package/dist/storage/SecureStoreAdapter.test.d.ts.map +1 -0
  174. package/dist/storage/SecureStoreAdapter.test.js +114 -0
  175. package/dist/storage/SecureStoreAdapter.test.js.map +1 -0
  176. package/dist/storage/defaultStorage.d.ts +1 -1
  177. package/dist/storage/defaultStorage.js +4 -4
  178. package/dist/storage/defaultStorage.test.d.ts +2 -0
  179. package/dist/storage/defaultStorage.test.d.ts.map +1 -0
  180. package/dist/storage/defaultStorage.test.js +159 -0
  181. package/dist/storage/defaultStorage.test.js.map +1 -0
  182. package/dist/storage/index.d.ts +5 -5
  183. package/dist/storage/types.js +1 -1
  184. package/dist/theme/defaults.d.ts +14 -3
  185. package/dist/theme/defaults.d.ts.map +1 -1
  186. package/dist/theme/defaults.js +58 -43
  187. package/dist/theme/defaults.js.map +1 -1
  188. package/dist/theme/index.d.ts +3 -2
  189. package/dist/theme/index.d.ts.map +1 -1
  190. package/dist/theme/index.js +4 -1
  191. package/dist/theme/index.js.map +1 -1
  192. package/dist/theme/resolver.d.ts +16 -0
  193. package/dist/theme/resolver.d.ts.map +1 -0
  194. package/dist/theme/resolver.js +375 -0
  195. package/dist/theme/resolver.js.map +1 -0
  196. package/dist/theme/resolver.test.d.ts +2 -0
  197. package/dist/theme/resolver.test.d.ts.map +1 -0
  198. package/dist/theme/resolver.test.js +344 -0
  199. package/dist/theme/resolver.test.js.map +1 -0
  200. package/dist/theme/types.d.ts +378 -5
  201. package/dist/theme/types.d.ts.map +1 -1
  202. package/dist/types/config.d.ts +4 -4
  203. package/dist/types/index.d.ts +2 -2
  204. package/dist/utils/index.d.ts +1 -1
  205. package/dist/utils/uuid.d.ts.map +1 -1
  206. package/dist/utils/uuid.js +4 -5
  207. package/dist/utils/uuid.js.map +1 -1
  208. package/dist/utils/uuid.test.d.ts +2 -0
  209. package/dist/utils/uuid.test.d.ts.map +1 -0
  210. package/dist/utils/uuid.test.js +78 -0
  211. package/dist/utils/uuid.test.js.map +1 -0
  212. package/package.json +21 -13
  213. package/src/@types/expo-file-system-legacy.d.ts +3 -3
  214. package/src/__mocks__/async-storage.ts +46 -0
  215. package/src/__mocks__/expo-document-picker.ts +41 -0
  216. package/src/__mocks__/expo-file-system.ts +62 -0
  217. package/src/__mocks__/expo-image-picker.ts +48 -0
  218. package/src/__mocks__/expo-secure-store.ts +29 -0
  219. package/src/__mocks__/react-native.ts +46 -0
  220. package/src/api/client.test.ts +515 -0
  221. package/src/api/client.ts +45 -64
  222. package/src/api/errors.test.ts +193 -0
  223. package/src/api/errors.ts +7 -11
  224. package/src/api/index.ts +6 -10
  225. package/src/api/retry.test.ts +251 -0
  226. package/src/api/retry.ts +3 -6
  227. package/src/attachments/FeedbackSheet.tsx +100 -80
  228. package/src/attachments/index.ts +2 -2
  229. package/src/components/AttachmentGrid.tsx +54 -45
  230. package/src/components/AttachmentPicker.tsx +43 -54
  231. package/src/components/AttachmentPreview.tsx +51 -47
  232. package/src/components/CategorySelector.tsx +29 -35
  233. package/src/components/FeedbackForm.tsx +23 -35
  234. package/src/components/FeedbackSheet.tsx +89 -68
  235. package/src/components/ThemedButton.tsx +49 -47
  236. package/src/components/ThemedText.tsx +7 -10
  237. package/src/components/ThemedTextInput.tsx +23 -13
  238. package/src/components/UploadStatusOverlay.tsx +66 -89
  239. package/src/components/index.ts +18 -21
  240. package/src/context/HarkenContext.tsx +29 -28
  241. package/src/context/index.ts +2 -2
  242. package/src/domain/index.ts +2 -5
  243. package/src/domain/upload-queue.ts +5 -5
  244. package/src/hooks/index.ts +5 -5
  245. package/src/hooks/useAnonymousId.test.ts +189 -0
  246. package/src/hooks/useAnonymousId.ts +3 -3
  247. package/src/hooks/useAttachmentPicker.ts +12 -12
  248. package/src/hooks/useAttachmentStatus.ts +12 -16
  249. package/src/hooks/useAttachmentUpload.test.ts +632 -0
  250. package/src/hooks/useAttachmentUpload.ts +45 -54
  251. package/src/hooks/useFeedback.test.ts +376 -0
  252. package/src/hooks/useFeedback.ts +12 -14
  253. package/src/hooks/useHarkenContext.ts +4 -4
  254. package/src/hooks/useHarkenTheme.ts +30 -6
  255. package/src/index.ts +28 -52
  256. package/src/services/index.ts +3 -9
  257. package/src/services/uploadQueueService.test.ts +489 -0
  258. package/src/services/uploadQueueService.ts +40 -56
  259. package/src/services/uploadQueueStorage.test.ts +243 -0
  260. package/src/services/uploadQueueStorage.ts +7 -9
  261. package/src/storage/IdentityStore.test.ts +173 -0
  262. package/src/storage/IdentityStore.ts +4 -5
  263. package/src/storage/SecureStoreAdapter.test.ts +147 -0
  264. package/src/storage/SecureStoreAdapter.ts +1 -1
  265. package/src/storage/defaultStorage.test.ts +159 -0
  266. package/src/storage/defaultStorage.ts +6 -6
  267. package/src/storage/index.ts +5 -5
  268. package/src/storage/types.ts +1 -1
  269. package/src/theme/defaults.ts +75 -46
  270. package/src/theme/index.ts +15 -2
  271. package/src/theme/resolver.test.ts +411 -0
  272. package/src/theme/resolver.ts +446 -0
  273. package/src/theme/types.ts +453 -15
  274. package/src/types/config.ts +4 -4
  275. package/src/types/index.ts +2 -2
  276. package/src/utils/index.ts +1 -1
  277. package/src/utils/uuid.test.ts +85 -0
  278. package/src/utils/uuid.ts +4 -7
@@ -0,0 +1,251 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ calculateRetryDelay,
4
+ isRetryableError,
5
+ withRetry,
6
+ DEFAULT_RETRY_CONFIG,
7
+ sleep,
8
+ } from "./retry";
9
+ import { HarkenApiError, HarkenNetworkError } from "./errors";
10
+
11
+ describe("calculateRetryDelay", () => {
12
+ beforeEach(() => {
13
+ // Mock Math.random for predictable jitter
14
+ vi.spyOn(Math, "random").mockReturnValue(0.5);
15
+ });
16
+
17
+ afterEach(() => {
18
+ vi.restoreAllMocks();
19
+ });
20
+
21
+ it("calculates exponential backoff correctly", () => {
22
+ const config = { ...DEFAULT_RETRY_CONFIG, jitter: 0 };
23
+
24
+ expect(calculateRetryDelay(0, config)).toBe(1000); // 1000 * 2^0 = 1000
25
+ expect(calculateRetryDelay(1, config)).toBe(2000); // 1000 * 2^1 = 2000
26
+ expect(calculateRetryDelay(2, config)).toBe(4000); // 1000 * 2^2 = 4000
27
+ expect(calculateRetryDelay(3, config)).toBe(8000); // 1000 * 2^3 = 8000
28
+ });
29
+
30
+ it("caps delay at maxDelay", () => {
31
+ const config = { ...DEFAULT_RETRY_CONFIG, jitter: 0, maxDelay: 5000 };
32
+
33
+ expect(calculateRetryDelay(0, config)).toBe(1000);
34
+ expect(calculateRetryDelay(3, config)).toBe(5000); // capped
35
+ expect(calculateRetryDelay(10, config)).toBe(5000); // capped
36
+ });
37
+
38
+ it("adds jitter within expected range", () => {
39
+ const config = { ...DEFAULT_RETRY_CONFIG, jitter: 0.1 };
40
+
41
+ // With random = 0.5, jitter should be 0 (centered)
42
+ // For attempt 0: base = 1000, jitterRange = 100, jitter = 0
43
+ const delay = calculateRetryDelay(0, config);
44
+ expect(delay).toBe(1000);
45
+ });
46
+
47
+ it("respects Retry-After header when provided", () => {
48
+ const config = DEFAULT_RETRY_CONFIG;
49
+
50
+ // 5 seconds should be 5000ms
51
+ expect(calculateRetryDelay(0, config, 5)).toBe(5000);
52
+ expect(calculateRetryDelay(2, config, 10)).toBe(10000);
53
+ });
54
+
55
+ it("caps Retry-After at maxDelay", () => {
56
+ const config = { ...DEFAULT_RETRY_CONFIG, maxDelay: 5000 };
57
+
58
+ // 60 seconds should be capped to 5000ms
59
+ expect(calculateRetryDelay(0, config, 60)).toBe(5000);
60
+ });
61
+
62
+ it("ignores zero or negative Retry-After", () => {
63
+ const config = { ...DEFAULT_RETRY_CONFIG, jitter: 0 };
64
+
65
+ // Should fall back to exponential backoff
66
+ expect(calculateRetryDelay(0, config, 0)).toBe(1000);
67
+ expect(calculateRetryDelay(0, config, -5)).toBe(1000);
68
+ });
69
+ });
70
+
71
+ describe("isRetryableError", () => {
72
+ it("returns true for HarkenNetworkError", () => {
73
+ const error = new HarkenNetworkError("Network failed");
74
+ expect(isRetryableError(error)).toBe(true);
75
+ });
76
+
77
+ it("returns true for HarkenApiError with 429 status", () => {
78
+ const error = new HarkenApiError(429, {
79
+ error: { code: "rate_limited", message: "Too many requests" },
80
+ });
81
+ expect(isRetryableError(error)).toBe(true);
82
+ });
83
+
84
+ it("returns true for HarkenApiError with 5xx status", () => {
85
+ const error500 = new HarkenApiError(500, {
86
+ error: { code: "internal_error", message: "Server error" },
87
+ });
88
+ const error503 = new HarkenApiError(503, {
89
+ error: { code: "service_unavailable", message: "Service unavailable" },
90
+ });
91
+ expect(isRetryableError(error500)).toBe(true);
92
+ expect(isRetryableError(error503)).toBe(true);
93
+ });
94
+
95
+ it("returns false for HarkenApiError with 4xx status (except 429)", () => {
96
+ const error400 = new HarkenApiError(400, {
97
+ error: { code: "validation_error", message: "Invalid input" },
98
+ });
99
+ const error401 = new HarkenApiError(401, {
100
+ error: { code: "unauthorized", message: "Unauthorized" },
101
+ });
102
+ const error404 = new HarkenApiError(404, {
103
+ error: { code: "not_found", message: "Not found" },
104
+ });
105
+ expect(isRetryableError(error400)).toBe(false);
106
+ expect(isRetryableError(error401)).toBe(false);
107
+ expect(isRetryableError(error404)).toBe(false);
108
+ });
109
+
110
+ it("returns false for generic Error", () => {
111
+ expect(isRetryableError(new Error("Generic error"))).toBe(false);
112
+ });
113
+
114
+ it("returns false for non-Error values", () => {
115
+ expect(isRetryableError(null)).toBe(false);
116
+ expect(isRetryableError(undefined)).toBe(false);
117
+ expect(isRetryableError("error string")).toBe(false);
118
+ expect(isRetryableError({ code: "error" })).toBe(false);
119
+ });
120
+ });
121
+
122
+ describe("sleep", () => {
123
+ beforeEach(() => {
124
+ vi.useFakeTimers();
125
+ });
126
+
127
+ afterEach(() => {
128
+ vi.useRealTimers();
129
+ });
130
+
131
+ it("resolves after specified delay", async () => {
132
+ const promise = sleep(1000);
133
+ vi.advanceTimersByTime(1000);
134
+ await expect(promise).resolves.toBeUndefined();
135
+ });
136
+ });
137
+
138
+ describe("withRetry", () => {
139
+ beforeEach(() => {
140
+ vi.useFakeTimers();
141
+ vi.spyOn(Math, "random").mockReturnValue(0.5);
142
+ });
143
+
144
+ afterEach(() => {
145
+ vi.useRealTimers();
146
+ vi.restoreAllMocks();
147
+ });
148
+
149
+ it("returns result on first successful attempt", async () => {
150
+ const fn = vi.fn().mockResolvedValue("success");
151
+
152
+ const result = await withRetry(fn);
153
+
154
+ expect(result).toBe("success");
155
+ expect(fn).toHaveBeenCalledTimes(1);
156
+ });
157
+
158
+ it("retries on retryable error and succeeds", async () => {
159
+ const fn = vi
160
+ .fn()
161
+ .mockRejectedValueOnce(new HarkenNetworkError("Network failed"))
162
+ .mockResolvedValue("success");
163
+
164
+ const promise = withRetry(fn, { maxRetries: 3 });
165
+
166
+ // First call fails immediately
167
+ await vi.advanceTimersByTimeAsync(0);
168
+
169
+ // Wait for retry delay (1000ms for attempt 0)
170
+ await vi.advanceTimersByTimeAsync(1000);
171
+
172
+ const result = await promise;
173
+ expect(result).toBe("success");
174
+ expect(fn).toHaveBeenCalledTimes(2);
175
+ });
176
+
177
+ it("throws after max retries exceeded", async () => {
178
+ const networkError = new HarkenNetworkError("Network failed");
179
+ const fn = vi.fn().mockRejectedValue(networkError);
180
+
181
+ // Attach rejection handler immediately to avoid unhandled rejection warning
182
+ let caughtError: unknown;
183
+ const promise = withRetry(fn, { maxRetries: 2, jitter: 0 }).catch((e) => {
184
+ caughtError = e;
185
+ });
186
+
187
+ // Initial attempt + retries with delays
188
+ await vi.advanceTimersByTimeAsync(0);
189
+ await vi.advanceTimersByTimeAsync(1000);
190
+ await vi.advanceTimersByTimeAsync(2000);
191
+
192
+ await promise;
193
+
194
+ expect(caughtError).toBe(networkError);
195
+ expect(fn).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
196
+ });
197
+
198
+ it("does not retry non-retryable errors", async () => {
199
+ const validationError = new HarkenApiError(400, {
200
+ error: { code: "validation_error", message: "Invalid input" },
201
+ });
202
+ const fn = vi.fn().mockRejectedValue(validationError);
203
+
204
+ await expect(withRetry(fn, { maxRetries: 3 })).rejects.toThrow(validationError);
205
+ expect(fn).toHaveBeenCalledTimes(1);
206
+ });
207
+
208
+ it("uses default config when none provided", async () => {
209
+ const fn = vi.fn().mockResolvedValue("success");
210
+
211
+ const result = await withRetry(fn);
212
+
213
+ expect(result).toBe("success");
214
+ expect(fn).toHaveBeenCalledTimes(1);
215
+ });
216
+
217
+ it("merges partial config with defaults", async () => {
218
+ const fn = vi
219
+ .fn()
220
+ .mockRejectedValueOnce(new HarkenNetworkError("Network failed"))
221
+ .mockResolvedValue("success");
222
+
223
+ const promise = withRetry(fn, { baseDelay: 500 });
224
+
225
+ await vi.advanceTimersByTimeAsync(0);
226
+ // Should use custom baseDelay of 500ms
227
+ await vi.advanceTimersByTimeAsync(500);
228
+
229
+ const result = await promise;
230
+ expect(result).toBe("success");
231
+ });
232
+
233
+ it("respects Retry-After from HarkenApiError", async () => {
234
+ const rateLimitError = new HarkenApiError(
235
+ 429,
236
+ { error: { code: "rate_limited", message: "Too many requests" } },
237
+ { retryAfter: 5 }
238
+ );
239
+ const fn = vi.fn().mockRejectedValueOnce(rateLimitError).mockResolvedValue("success");
240
+
241
+ const promise = withRetry(fn, { maxRetries: 3, jitter: 0 });
242
+
243
+ await vi.advanceTimersByTimeAsync(0);
244
+ // Should wait 5000ms (Retry-After: 5 seconds)
245
+ await vi.advanceTimersByTimeAsync(5000);
246
+
247
+ const result = await promise;
248
+ expect(result).toBe("success");
249
+ expect(fn).toHaveBeenCalledTimes(2);
250
+ });
251
+ });
package/src/api/retry.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { HarkenApiError, HarkenNetworkError } from './errors';
1
+ import { HarkenApiError, HarkenNetworkError } from "./errors";
2
2
 
3
3
  export interface RetryConfig {
4
4
  /** Maximum number of retry attempts (default: 3) */
@@ -45,9 +45,7 @@ export function calculateRetryDelay(
45
45
  /**
46
46
  * Check if an error is retryable.
47
47
  */
48
- export function isRetryableError(
49
- error: unknown
50
- ): error is HarkenApiError | HarkenNetworkError {
48
+ export function isRetryableError(error: unknown): error is HarkenApiError | HarkenNetworkError {
51
49
  if (error instanceof HarkenApiError) {
52
50
  return error.isRetryable;
53
51
  }
@@ -86,8 +84,7 @@ export async function withRetry<T>(
86
84
  }
87
85
 
88
86
  // Calculate delay, respecting Retry-After header if present
89
- const retryAfter =
90
- error instanceof HarkenApiError ? error.retryAfter : undefined;
87
+ const retryAfter = error instanceof HarkenApiError ? error.retryAfter : undefined;
91
88
 
92
89
  const delay = calculateRetryDelay(attempt, fullConfig, retryAfter);
93
90
  await sleep(delay);
@@ -1,26 +1,20 @@
1
- import React, { useState, useCallback } from 'react';
2
- import {
3
- View,
4
- KeyboardAvoidingView,
5
- Platform,
6
- ScrollView,
7
- Alert,
8
- } from 'react-native';
9
- import type { ViewStyle } from 'react-native';
10
- import type { components } from '../types/index.js';
11
- import { useHarkenTheme, useFeedback } from '../hooks';
12
- import { ThemedText } from '../components/ThemedText';
13
- import { ThemedTextInput } from '../components/ThemedTextInput';
14
- import { ThemedButton } from '../components/ThemedButton';
15
- import { CategorySelector, DEFAULT_CATEGORIES } from '../components/CategorySelector';
16
- import type { CategoryOption } from '../components/CategorySelector';
17
- import type { FeedbackCategory } from '../types';
18
- import { useAttachmentPicker } from '../hooks/useAttachmentPicker';
19
- import type { AttachmentSourceConfig } from '../hooks/useAttachmentPicker';
20
- import { AttachmentGrid } from '../components/AttachmentGrid';
21
- import { AttachmentPicker } from '../components/AttachmentPicker';
22
-
23
- type FeedbackSubmissionResponse = components['schemas']['FeedbackSubmissionResponse'];
1
+ import React, { useState, useCallback } from "react";
2
+ import { View, KeyboardAvoidingView, Platform, ScrollView, Alert } from "react-native";
3
+ import type { ViewStyle, StyleProp } from "react-native";
4
+ import type { components } from "../types/index.js";
5
+ import { useHarkenTheme, useFeedback } from "../hooks";
6
+ import { ThemedText } from "../components/ThemedText";
7
+ import { ThemedTextInput } from "../components/ThemedTextInput";
8
+ import { ThemedButton } from "../components/ThemedButton";
9
+ import { CategorySelector, DEFAULT_CATEGORIES } from "../components/CategorySelector";
10
+ import type { CategoryOption } from "../components/CategorySelector";
11
+ import type { FeedbackCategory } from "../types";
12
+ import { useAttachmentPicker } from "../hooks/useAttachmentPicker";
13
+ import type { AttachmentSourceConfig } from "../hooks/useAttachmentPicker";
14
+ import { AttachmentGrid } from "../components/AttachmentGrid";
15
+ import { AttachmentPicker } from "../components/AttachmentPicker";
16
+
17
+ type FeedbackSubmissionResponse = components["schemas"]["FeedbackSubmissionResponse"];
24
18
 
25
19
  export interface FeedbackSheetProps {
26
20
  /** Called when feedback is successfully submitted */
@@ -30,7 +24,7 @@ export interface FeedbackSheetProps {
30
24
  /** Called when user cancels/dismisses the form */
31
25
  onCancel?: () => void;
32
26
 
33
- /** Title text */
27
+ /** Title text. Set to empty string to hide title section entirely. */
34
28
  title?: string;
35
29
  /** Placeholder text for message input */
36
30
  placeholder?: string;
@@ -67,10 +61,21 @@ export interface FeedbackSheetProps {
67
61
  /** Whether to clear form on success. @default true */
68
62
  clearOnSuccess?: boolean;
69
63
 
70
- /** Container style override */
71
- containerStyle?: ViewStyle;
72
- /** Form content style override */
73
- formStyle?: ViewStyle;
64
+ /**
65
+ * Layout mode for the container.
66
+ * - 'flex': Uses flex: 1 (default, requires parent with explicit height)
67
+ * - 'auto': Content determines height (for bottom sheet modal embedding)
68
+ */
69
+ layout?: "flex" | "auto";
70
+
71
+ /** Container style override (outer KeyboardAvoidingView) */
72
+ containerStyle?: StyleProp<ViewStyle>;
73
+ /** Content style override (inner ScrollView content) */
74
+ contentStyle?: StyleProp<ViewStyle>;
75
+ /**
76
+ * @deprecated Use `contentStyle` instead
77
+ */
78
+ formStyle?: StyleProp<ViewStyle>;
74
79
  }
75
80
 
76
81
  /**
@@ -81,13 +86,26 @@ export interface FeedbackSheetProps {
81
86
  *
82
87
  * For the version without attachment dependencies, import from the main entry point.
83
88
  *
89
+ * Uses the following theme tokens:
90
+ * - `colors.formBackground` for background
91
+ * - `spacing.formPadding` for padding
92
+ * - `spacing.sectionGap` for section gaps
93
+ * - `radii.form` for border radius
94
+ *
84
95
  * @example
85
96
  * ```tsx
86
- * import { FeedbackSheet } from '@harkenapp/sdk-react-native/attachments';
97
+ * import { FeedbackSheet } from '@harkenapp/sdk-react-native';
87
98
  *
88
99
  * // Minimal usage with attachments
89
100
  * <FeedbackSheet onSuccess={() => navigation.goBack()} />
90
101
  *
102
+ * // For bottom sheet modal embedding
103
+ * <FeedbackSheet
104
+ * layout="auto"
105
+ * title=""
106
+ * onSuccess={() => closeModal()}
107
+ * />
108
+ *
91
109
  * // With customization
92
110
  * <FeedbackSheet
93
111
  * title="Report a Bug"
@@ -112,10 +130,10 @@ export function FeedbackSheet({
112
130
  onSuccess,
113
131
  onError,
114
132
  onCancel,
115
- title = 'Send Feedback',
116
- placeholder = 'What would you like to share?',
117
- submitLabel = 'Submit',
118
- cancelLabel = 'Cancel',
133
+ title = "Send Feedback",
134
+ placeholder = "What would you like to share?",
135
+ submitLabel = "Submit",
136
+ cancelLabel = "Cancel",
119
137
  categories = DEFAULT_CATEGORIES,
120
138
  requireCategory = false,
121
139
  minMessageLength = 1,
@@ -123,15 +141,17 @@ export function FeedbackSheet({
123
141
  enableAttachments = true,
124
142
  maxAttachments = 5,
125
143
  attachmentSources,
126
- successMessage = 'Thank you for your feedback!',
144
+ successMessage = "Thank you for your feedback!",
127
145
  showSuccessAlert = true,
128
146
  clearOnSuccess = true,
147
+ layout = "flex",
129
148
  containerStyle,
149
+ contentStyle,
130
150
  formStyle,
131
151
  }: FeedbackSheetProps): React.JSX.Element {
132
152
  const theme = useHarkenTheme();
133
- const { submitFeedback, isSubmitting, error, clearError, isInitializing } =
134
- useFeedback();
153
+ const { form } = theme.components;
154
+ const { submitFeedback, isSubmitting, error, clearError, isInitializing } = useFeedback();
135
155
  const {
136
156
  attachments,
137
157
  removeAttachment,
@@ -143,18 +163,17 @@ export function FeedbackSheet({
143
163
  enabledSourceCount,
144
164
  } = useAttachmentPicker(attachmentSources);
145
165
 
146
- const [message, setMessage] = useState('');
166
+ const [message, setMessage] = useState("");
147
167
  const [category, setCategory] = useState<FeedbackCategory | null>(null);
148
168
 
149
169
  const trimmedMessage = message.trim();
150
170
  const isMessageValid =
151
- trimmedMessage.length >= minMessageLength &&
152
- trimmedMessage.length <= maxMessageLength;
171
+ trimmedMessage.length >= minMessageLength && trimmedMessage.length <= maxMessageLength;
153
172
  const isCategoryValid = !requireCategory || category !== null;
154
173
  const canSubmit = isMessageValid && isCategoryValid && !isSubmitting && !isInitializing;
155
174
 
156
175
  const resetForm = useCallback(() => {
157
- setMessage('');
176
+ setMessage("");
158
177
  setCategory(null);
159
178
  clearError();
160
179
  // Note: We don't clear attachments since they may still be uploading
@@ -169,18 +188,19 @@ export function FeedbackSheet({
169
188
  try {
170
189
  const result = await submitFeedback({
171
190
  message: trimmedMessage,
172
- category: category ?? 'other',
191
+ category: category ?? "other",
173
192
  attachments: enableAttachments ? getAttachmentIds() : undefined,
174
193
  });
175
194
 
176
- const uploadNote = enableAttachments && hasActiveUploads
177
- ? '\n\nAttachments are still uploading in the background.'
178
- : '';
195
+ const uploadNote =
196
+ enableAttachments && hasActiveUploads
197
+ ? "\n\nAttachments are still uploading in the background."
198
+ : "";
179
199
 
180
200
  if (showSuccessAlert && successMessage) {
181
- Alert.alert('Success', `${successMessage}${uploadNote}`, [
201
+ Alert.alert("Success", `${successMessage}${uploadNote}`, [
182
202
  {
183
- text: 'OK',
203
+ text: "OK",
184
204
  onPress: () => {
185
205
  if (clearOnSuccess) {
186
206
  resetForm();
@@ -197,8 +217,8 @@ export function FeedbackSheet({
197
217
  }
198
218
  } catch (e) {
199
219
  const errorMessage =
200
- e instanceof Error ? e.message : 'Failed to submit feedback. Please try again.';
201
- Alert.alert('Submission Failed', errorMessage);
220
+ e instanceof Error ? e.message : "Failed to submit feedback. Please try again.";
221
+ Alert.alert("Submission Failed", errorMessage);
202
222
  onError?.(e instanceof Error ? e : new Error(errorMessage));
203
223
  }
204
224
  }, [
@@ -224,21 +244,25 @@ export function FeedbackSheet({
224
244
  }, [resetForm, onCancel]);
225
245
 
226
246
  const baseContainerStyle: ViewStyle = {
227
- flex: 1,
228
- backgroundColor: theme.colors.background,
247
+ ...(layout === "flex" ? { flex: 1 } : {}),
248
+ backgroundColor: form.background,
249
+ borderRadius: form.radius,
229
250
  };
230
251
 
231
- const contentStyle: ViewStyle = {
232
- flexGrow: 1,
233
- padding: theme.spacing.lg,
252
+ const scrollContentStyle: ViewStyle = {
253
+ ...(layout === "flex" ? { flexGrow: 1 } : {}),
254
+ padding: form.padding,
234
255
  };
235
256
 
236
257
  const sectionStyle: ViewStyle = {
237
- marginBottom: theme.spacing.lg,
258
+ marginBottom: form.sectionGap,
238
259
  };
239
260
 
261
+ // Support deprecated formStyle prop
262
+ const effectiveContentStyle = contentStyle ?? formStyle;
263
+
240
264
  const buttonRowStyle: ViewStyle = {
241
- flexDirection: 'row',
265
+ flexDirection: "row",
242
266
  gap: theme.spacing.sm,
243
267
  marginTop: theme.spacing.md,
244
268
  };
@@ -248,7 +272,13 @@ export function FeedbackSheet({
248
272
 
249
273
  if (isInitializing) {
250
274
  return (
251
- <View style={[baseContainerStyle, containerStyle, { justifyContent: 'center', alignItems: 'center' }]}>
275
+ <View
276
+ style={[
277
+ baseContainerStyle,
278
+ containerStyle,
279
+ { justifyContent: "center", alignItems: "center" },
280
+ ]}
281
+ >
252
282
  <ThemedText variant="body" secondary>
253
283
  Initializing...
254
284
  </ThemedText>
@@ -259,26 +289,24 @@ export function FeedbackSheet({
259
289
  return (
260
290
  <>
261
291
  <KeyboardAvoidingView
262
- behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
292
+ behavior={Platform.OS === "ios" ? "padding" : "height"}
263
293
  style={[baseContainerStyle, containerStyle]}
264
294
  >
265
295
  <ScrollView
266
- contentContainerStyle={[contentStyle, formStyle]}
296
+ contentContainerStyle={[scrollContentStyle, effectiveContentStyle]}
267
297
  keyboardShouldPersistTaps="handled"
268
298
  >
269
- {/* Title */}
270
- <View style={sectionStyle}>
271
- <ThemedText variant="title">{title}</ThemedText>
272
- </View>
299
+ {/* Title - only render if provided */}
300
+ {title ? (
301
+ <View style={sectionStyle}>
302
+ <ThemedText variant="title">{title}</ThemedText>
303
+ </View>
304
+ ) : null}
273
305
 
274
306
  {/* Category selector */}
275
307
  <View style={sectionStyle}>
276
- <ThemedText
277
- variant="label"
278
- secondary
279
- style={{ marginBottom: theme.spacing.sm }}
280
- >
281
- Category{requireCategory ? '' : ' (optional)'}
308
+ <ThemedText variant="label" secondary style={{ marginBottom: theme.spacing.sm }}>
309
+ Category{requireCategory ? "" : " (optional)"}
282
310
  </ThemedText>
283
311
  <CategorySelector
284
312
  value={category}
@@ -290,11 +318,7 @@ export function FeedbackSheet({
290
318
 
291
319
  {/* Message input */}
292
320
  <View style={sectionStyle}>
293
- <ThemedText
294
- variant="label"
295
- secondary
296
- style={{ marginBottom: theme.spacing.sm }}
297
- >
321
+ <ThemedText variant="label" secondary style={{ marginBottom: theme.spacing.sm }}>
298
322
  Message
299
323
  </ThemedText>
300
324
  <ThemedTextInput
@@ -316,7 +340,7 @@ export function FeedbackSheet({
316
340
  ? theme.colors.error
317
341
  : theme.colors.textSecondary
318
342
  }
319
- style={{ marginTop: theme.spacing.xs, textAlign: 'right' }}
343
+ style={{ marginTop: theme.spacing.xs, textAlign: "right" }}
320
344
  >
321
345
  {characterCount}/{maxMessageLength}
322
346
  </ThemedText>
@@ -326,11 +350,7 @@ export function FeedbackSheet({
326
350
  {/* Attachments */}
327
351
  {enableAttachments && (
328
352
  <View style={sectionStyle}>
329
- <ThemedText
330
- variant="label"
331
- secondary
332
- style={{ marginBottom: theme.spacing.sm }}
333
- >
353
+ <ThemedText variant="label" secondary style={{ marginBottom: theme.spacing.sm }}>
334
354
  Attachments
335
355
  </ThemedText>
336
356
  <AttachmentGrid
@@ -384,7 +404,7 @@ export function FeedbackSheet({
384
404
  <ThemedText
385
405
  variant="caption"
386
406
  color={theme.colors.primary}
387
- style={{ textAlign: 'center' }}
407
+ style={{ textAlign: "center" }}
388
408
  >
389
409
  Uploads in progress - you can still submit now
390
410
  </ThemedText>
@@ -38,7 +38,7 @@ export {
38
38
  // Domain types
39
39
  UploadPhase,
40
40
  DEFAULT_UPLOAD_RETRY_CONFIG,
41
- } from '../index';
41
+ } from "../index";
42
42
 
43
43
  export type {
44
44
  // Attachment hook types
@@ -67,4 +67,4 @@ export type {
67
67
  QueueStatus,
68
68
  UploadProgress,
69
69
  UploadRetryConfig,
70
- } from '../index';
70
+ } from "../index";