@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
@@ -5,13 +5,14 @@
5
5
  * Uploads happen in background via the singleton uploadQueueService.
6
6
  */
7
7
 
8
- import { useState, useEffect, useCallback, useRef } from 'react';
9
- import * as ImagePicker from 'expo-image-picker';
10
- import * as DocumentPicker from 'expo-document-picker';
11
- import * as FileSystem from 'expo-file-system/legacy';
12
- import { uploadQueueService } from '../services';
13
- import { UploadPhase, UploadProgress } from '../domain';
14
- import { useHarkenContext } from './useHarkenContext';
8
+ import { useState, useEffect, useCallback, useRef } from "react";
9
+ import * as ImagePicker from "expo-image-picker";
10
+ import * as DocumentPicker from "expo-document-picker";
11
+ import * as FileSystem from "expo-file-system/legacy";
12
+ import { uploadQueueService } from "../services";
13
+ import type { UploadProgress } from "../domain";
14
+ import { UploadPhase } from "../domain";
15
+ import { useHarkenContext } from "./useHarkenContext";
15
16
 
16
17
  /**
17
18
  * State for a single attachment.
@@ -41,7 +42,7 @@ export interface UseAttachmentUploadResult {
41
42
  attachments: AttachmentState[];
42
43
 
43
44
  /** Pick image from camera or library */
44
- pickImage: (source: 'camera' | 'library') => Promise<AttachmentState | null>;
45
+ pickImage: (source: "camera" | "library") => Promise<AttachmentState | null>;
45
46
 
46
47
  /** Pick document (images or PDFs) */
47
48
  pickDocument: () => Promise<AttachmentState | null>;
@@ -118,9 +119,7 @@ export interface UseAttachmentUploadResult {
118
119
  */
119
120
  export function useAttachmentUpload(): UseAttachmentUploadResult {
120
121
  const { client, config } = useHarkenContext();
121
- const [attachments, setAttachments] = useState<Map<string, AttachmentState>>(
122
- new Map()
123
- );
122
+ const [attachments, setAttachments] = useState<Map<string, AttachmentState>>(new Map());
124
123
 
125
124
  // Track which attachment IDs this hook instance is managing
126
125
  const attachmentIdsRef = useRef<Set<string>>(new Set());
@@ -142,26 +141,24 @@ export function useAttachmentUpload(): UseAttachmentUploadResult {
142
141
  return;
143
142
  }
144
143
 
145
- const unsubProgress = uploadQueueService.onProgress(
146
- (progress: UploadProgress) => {
147
- // Only track attachments we added
148
- if (!attachmentIdsRef.current.has(progress.attachmentId)) return;
149
-
150
- setAttachments((prev) => {
151
- const existing = prev.get(progress.attachmentId);
152
- if (!existing) return prev;
153
-
154
- const next = new Map(prev);
155
- next.set(progress.attachmentId, {
156
- ...existing,
157
- phase: progress.phase,
158
- progress: progress.progress,
159
- error: progress.error,
160
- });
161
- return next;
144
+ const unsubProgress = uploadQueueService.onProgress((progress: UploadProgress) => {
145
+ // Only track attachments we added
146
+ if (!attachmentIdsRef.current.has(progress.attachmentId)) return;
147
+
148
+ setAttachments((prev) => {
149
+ const existing = prev.get(progress.attachmentId);
150
+ if (!existing) return prev;
151
+
152
+ const next = new Map(prev);
153
+ next.set(progress.attachmentId, {
154
+ ...existing,
155
+ phase: progress.phase,
156
+ progress: progress.progress,
157
+ error: progress.error,
162
158
  });
163
- }
164
- );
159
+ return next;
160
+ });
161
+ });
165
162
 
166
163
  return () => {
167
164
  unsubProgress();
@@ -206,14 +203,14 @@ export function useAttachmentUpload(): UseAttachmentUploadResult {
206
203
  * Pick an image from camera or photo library.
207
204
  */
208
205
  const pickImage = useCallback(
209
- async (source: 'camera' | 'library'): Promise<AttachmentState | null> => {
206
+ async (source: "camera" | "library"): Promise<AttachmentState | null> => {
210
207
  const options: ImagePicker.ImagePickerOptions = {
211
- mediaTypes: ['images'],
208
+ mediaTypes: ["images"],
212
209
  quality: 0.8,
213
210
  };
214
211
 
215
212
  const result =
216
- source === 'camera'
213
+ source === "camera"
217
214
  ? await ImagePicker.launchCameraAsync(options)
218
215
  : await ImagePicker.launchImageLibraryAsync(options);
219
216
 
@@ -223,7 +220,7 @@ export function useAttachmentUpload(): UseAttachmentUploadResult {
223
220
 
224
221
  const asset = result.assets[0];
225
222
  const fileName = asset.fileName ?? `image_${Date.now()}.jpg`;
226
- const mimeType = asset.mimeType ?? 'image/jpeg';
223
+ const mimeType = asset.mimeType ?? "image/jpeg";
227
224
 
228
225
  // Get file size - use asset.fileSize if available, otherwise query filesystem
229
226
  let fileSize = asset.fileSize;
@@ -247,7 +244,7 @@ export function useAttachmentUpload(): UseAttachmentUploadResult {
247
244
  */
248
245
  const pickDocument = useCallback(async (): Promise<AttachmentState | null> => {
249
246
  const result = await DocumentPicker.getDocumentAsync({
250
- type: ['image/*', 'application/pdf'],
247
+ type: ["image/*", "application/pdf"],
251
248
  copyToCacheDirectory: true,
252
249
  });
253
250
 
@@ -266,7 +263,7 @@ export function useAttachmentUpload(): UseAttachmentUploadResult {
266
263
 
267
264
  return addAttachment({
268
265
  uri: asset.uri,
269
- mimeType: asset.mimeType ?? 'application/octet-stream',
266
+ mimeType: asset.mimeType ?? "application/octet-stream",
270
267
  fileName: asset.name,
271
268
  fileSize,
272
269
  });
@@ -275,28 +272,22 @@ export function useAttachmentUpload(): UseAttachmentUploadResult {
275
272
  /**
276
273
  * Retry a failed attachment upload.
277
274
  */
278
- const retryAttachment = useCallback(
279
- async (attachmentId: string): Promise<void> => {
280
- await uploadQueueService.retryItem(attachmentId);
281
- },
282
- []
283
- );
275
+ const retryAttachment = useCallback(async (attachmentId: string): Promise<void> => {
276
+ await uploadQueueService.retryItem(attachmentId);
277
+ }, []);
284
278
 
285
279
  /**
286
280
  * Remove an attachment (cancels upload if in progress).
287
281
  */
288
- const removeAttachment = useCallback(
289
- async (attachmentId: string): Promise<void> => {
290
- await uploadQueueService.cancelItem(attachmentId);
291
- attachmentIdsRef.current.delete(attachmentId);
292
- setAttachments((prev) => {
293
- const next = new Map(prev);
294
- next.delete(attachmentId);
295
- return next;
296
- });
297
- },
298
- []
299
- );
282
+ const removeAttachment = useCallback(async (attachmentId: string): Promise<void> => {
283
+ await uploadQueueService.cancelItem(attachmentId);
284
+ attachmentIdsRef.current.delete(attachmentId);
285
+ setAttachments((prev) => {
286
+ const next = new Map(prev);
287
+ next.delete(attachmentId);
288
+ return next;
289
+ });
290
+ }, []);
300
291
 
301
292
  /**
302
293
  * Get all attachment IDs for feedback submission.
@@ -0,0 +1,376 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { describe, it, expect, vi, beforeEach } from "vitest";
5
+ import { renderHook, act, waitFor } from "@testing-library/react";
6
+ import { useFeedback } from "./useFeedback";
7
+ import { HarkenApiError, HarkenNetworkError } from "../api/errors";
8
+ import type { FeedbackCategory } from "../types";
9
+
10
+ // Mock dependencies
11
+ vi.mock("react-native", () => ({
12
+ Platform: { OS: "ios" },
13
+ }));
14
+
15
+ const mockSubmitFeedback = vi.fn();
16
+
17
+ vi.mock("../api/client", () => ({
18
+ HarkenClient: class MockHarkenClient {
19
+ submitFeedback = mockSubmitFeedback;
20
+ },
21
+ }));
22
+
23
+ vi.mock("./useHarkenContext", () => ({
24
+ useHarkenContext: vi.fn(() => ({
25
+ config: {
26
+ publishableKey: "pk_test_123",
27
+ userToken: undefined,
28
+ apiBaseUrl: "https://api.harken.app",
29
+ },
30
+ })),
31
+ }));
32
+
33
+ import { useAnonymousId } from "./useAnonymousId";
34
+
35
+ vi.mock("./useAnonymousId", () => ({
36
+ useAnonymousId: vi.fn(() => ({
37
+ anonymousId: "test-anon-id-123",
38
+ isLoading: false,
39
+ })),
40
+ }));
41
+
42
+ describe("useFeedback", () => {
43
+ beforeEach(() => {
44
+ vi.clearAllMocks();
45
+ });
46
+
47
+ describe("payload construction", () => {
48
+ it("sends correct payload to client", async () => {
49
+ mockSubmitFeedback.mockResolvedValue({ id: "feedback_123" });
50
+
51
+ const { result } = renderHook(() => useFeedback());
52
+
53
+ await act(async () => {
54
+ await result.current.submitFeedback({
55
+ message: "Great app!",
56
+ category: "idea" as FeedbackCategory,
57
+ });
58
+ });
59
+
60
+ expect(mockSubmitFeedback).toHaveBeenCalledWith({
61
+ message: "Great app!",
62
+ category: "idea",
63
+ title: undefined,
64
+ anon_id: "test-anon-id-123",
65
+ metadata: { platform: "ios" },
66
+ attachments: undefined,
67
+ });
68
+ });
69
+
70
+ it("includes title when provided", async () => {
71
+ mockSubmitFeedback.mockResolvedValue({ id: "feedback_123" });
72
+
73
+ const { result } = renderHook(() => useFeedback());
74
+
75
+ await act(async () => {
76
+ await result.current.submitFeedback({
77
+ message: "Bug description",
78
+ category: "bug" as FeedbackCategory,
79
+ title: "Login issue",
80
+ });
81
+ });
82
+
83
+ expect(mockSubmitFeedback).toHaveBeenCalledWith(
84
+ expect.objectContaining({
85
+ title: "Login issue",
86
+ })
87
+ );
88
+ });
89
+
90
+ it("includes attachments when provided", async () => {
91
+ mockSubmitFeedback.mockResolvedValue({ id: "feedback_123" });
92
+
93
+ const { result } = renderHook(() => useFeedback());
94
+
95
+ await act(async () => {
96
+ await result.current.submitFeedback({
97
+ message: "See attached screenshot",
98
+ category: "bug" as FeedbackCategory,
99
+ attachments: ["att_123", "att_456"],
100
+ });
101
+ });
102
+
103
+ expect(mockSubmitFeedback).toHaveBeenCalledWith(
104
+ expect.objectContaining({
105
+ attachments: ["att_123", "att_456"],
106
+ })
107
+ );
108
+ });
109
+
110
+ it("merges custom metadata with platform", async () => {
111
+ mockSubmitFeedback.mockResolvedValue({ id: "feedback_123" });
112
+
113
+ const { result } = renderHook(() => useFeedback());
114
+
115
+ await act(async () => {
116
+ await result.current.submitFeedback({
117
+ message: "Test",
118
+ category: "other" as FeedbackCategory,
119
+ metadata: { app_version: "1.2.3", screen: "settings" },
120
+ });
121
+ });
122
+
123
+ expect(mockSubmitFeedback).toHaveBeenCalledWith(
124
+ expect.objectContaining({
125
+ metadata: {
126
+ platform: "ios",
127
+ app_version: "1.2.3",
128
+ screen: "settings",
129
+ },
130
+ })
131
+ );
132
+ });
133
+ });
134
+
135
+ describe("loading state", () => {
136
+ it("sets isSubmitting true during submission", async () => {
137
+ let resolveSubmit: (value: unknown) => void;
138
+ const submitPromise = new Promise((resolve) => {
139
+ resolveSubmit = resolve;
140
+ });
141
+ mockSubmitFeedback.mockReturnValue(submitPromise);
142
+
143
+ const { result } = renderHook(() => useFeedback());
144
+
145
+ expect(result.current.isSubmitting).toBe(false);
146
+
147
+ let submitPromiseResult: Promise<unknown>;
148
+ act(() => {
149
+ submitPromiseResult = result.current.submitFeedback({
150
+ message: "Test",
151
+ category: "idea" as FeedbackCategory,
152
+ });
153
+ });
154
+
155
+ await waitFor(() => {
156
+ expect(result.current.isSubmitting).toBe(true);
157
+ });
158
+
159
+ await act(async () => {
160
+ resolveSubmit!({ id: "feedback_123" });
161
+ await submitPromiseResult;
162
+ });
163
+
164
+ expect(result.current.isSubmitting).toBe(false);
165
+ });
166
+
167
+ it("sets isSubmitting false after error", async () => {
168
+ mockSubmitFeedback.mockRejectedValue(
169
+ new HarkenApiError(400, { error: { code: "validation_error", message: "Invalid" } })
170
+ );
171
+
172
+ const { result } = renderHook(() => useFeedback());
173
+
174
+ await act(async () => {
175
+ try {
176
+ await result.current.submitFeedback({
177
+ message: "Test",
178
+ category: "idea" as FeedbackCategory,
179
+ });
180
+ } catch {
181
+ // Expected
182
+ }
183
+ });
184
+
185
+ expect(result.current.isSubmitting).toBe(false);
186
+ });
187
+ });
188
+
189
+ describe("error handling", () => {
190
+ it("sets error state on API failure", async () => {
191
+ const apiError = new HarkenApiError(400, {
192
+ error: { code: "validation_error", message: "Message is required" },
193
+ });
194
+ mockSubmitFeedback.mockRejectedValue(apiError);
195
+
196
+ const { result } = renderHook(() => useFeedback());
197
+
198
+ await act(async () => {
199
+ try {
200
+ await result.current.submitFeedback({
201
+ message: "",
202
+ category: "idea" as FeedbackCategory,
203
+ });
204
+ } catch {
205
+ // Expected
206
+ }
207
+ });
208
+
209
+ expect(result.current.error).toBe(apiError);
210
+ });
211
+
212
+ it("sets error state on network failure", async () => {
213
+ const networkError = new HarkenNetworkError("Network request failed");
214
+ mockSubmitFeedback.mockRejectedValue(networkError);
215
+
216
+ const { result } = renderHook(() => useFeedback());
217
+
218
+ await act(async () => {
219
+ try {
220
+ await result.current.submitFeedback({
221
+ message: "Test",
222
+ category: "idea" as FeedbackCategory,
223
+ });
224
+ } catch {
225
+ // Expected
226
+ }
227
+ });
228
+
229
+ expect(result.current.error).toBe(networkError);
230
+ });
231
+
232
+ it("wraps unknown errors in HarkenNetworkError", async () => {
233
+ mockSubmitFeedback.mockRejectedValue(new Error("Something went wrong"));
234
+
235
+ const { result } = renderHook(() => useFeedback());
236
+
237
+ await act(async () => {
238
+ try {
239
+ await result.current.submitFeedback({
240
+ message: "Test",
241
+ category: "idea" as FeedbackCategory,
242
+ });
243
+ } catch {
244
+ // Expected
245
+ }
246
+ });
247
+
248
+ expect(result.current.error).toBeInstanceOf(HarkenNetworkError);
249
+ expect(result.current.error?.message).toBe("Something went wrong");
250
+ });
251
+
252
+ it("clears error on successful submission", async () => {
253
+ // First, cause an error
254
+ mockSubmitFeedback.mockRejectedValueOnce(
255
+ new HarkenApiError(500, { error: { code: "server_error", message: "Error" } })
256
+ );
257
+
258
+ const { result } = renderHook(() => useFeedback());
259
+
260
+ await act(async () => {
261
+ try {
262
+ await result.current.submitFeedback({
263
+ message: "Test",
264
+ category: "idea" as FeedbackCategory,
265
+ });
266
+ } catch {
267
+ // Expected
268
+ }
269
+ });
270
+
271
+ expect(result.current.error).not.toBeNull();
272
+
273
+ // Now succeed
274
+ mockSubmitFeedback.mockResolvedValueOnce({ id: "feedback_123" });
275
+
276
+ await act(async () => {
277
+ await result.current.submitFeedback({
278
+ message: "Test",
279
+ category: "idea" as FeedbackCategory,
280
+ });
281
+ });
282
+
283
+ expect(result.current.error).toBeNull();
284
+ });
285
+
286
+ it("clearError clears the error state", async () => {
287
+ mockSubmitFeedback.mockRejectedValue(
288
+ new HarkenApiError(400, { error: { code: "error", message: "Error" } })
289
+ );
290
+
291
+ const { result } = renderHook(() => useFeedback());
292
+
293
+ await act(async () => {
294
+ try {
295
+ await result.current.submitFeedback({
296
+ message: "Test",
297
+ category: "idea" as FeedbackCategory,
298
+ });
299
+ } catch {
300
+ // Expected
301
+ }
302
+ });
303
+
304
+ expect(result.current.error).not.toBeNull();
305
+
306
+ act(() => {
307
+ result.current.clearError();
308
+ });
309
+
310
+ expect(result.current.error).toBeNull();
311
+ });
312
+ });
313
+
314
+ describe("successful submission", () => {
315
+ it("returns response from client", async () => {
316
+ const expectedResponse = { id: "feedback_123", status: "received" };
317
+ mockSubmitFeedback.mockResolvedValue(expectedResponse);
318
+
319
+ const { result } = renderHook(() => useFeedback());
320
+
321
+ let response: unknown;
322
+ await act(async () => {
323
+ response = await result.current.submitFeedback({
324
+ message: "Great app!",
325
+ category: "praise" as FeedbackCategory,
326
+ });
327
+ });
328
+
329
+ expect(response).toEqual(expectedResponse);
330
+ });
331
+ });
332
+
333
+ describe("anonymousId requirement", () => {
334
+ it("throws when anonymousId is not available", async () => {
335
+ // Override mock to return null anonymousId
336
+ vi.mocked(useAnonymousId).mockReturnValue({
337
+ anonymousId: null,
338
+ isLoading: false,
339
+ });
340
+
341
+ const { result } = renderHook(() => useFeedback());
342
+
343
+ await expect(
344
+ act(async () => {
345
+ await result.current.submitFeedback({
346
+ message: "Test",
347
+ category: "idea" as FeedbackCategory,
348
+ });
349
+ })
350
+ ).rejects.toThrow("Anonymous ID not yet initialized");
351
+
352
+ // Restore default mock
353
+ vi.mocked(useAnonymousId).mockReturnValue({
354
+ anonymousId: "test-anon-id-123",
355
+ isLoading: false,
356
+ });
357
+ });
358
+
359
+ it("exposes isInitializing from useAnonymousId", () => {
360
+ vi.mocked(useAnonymousId).mockReturnValue({
361
+ anonymousId: null,
362
+ isLoading: true,
363
+ });
364
+
365
+ const { result } = renderHook(() => useFeedback());
366
+
367
+ expect(result.current.isInitializing).toBe(true);
368
+
369
+ // Restore default mock
370
+ vi.mocked(useAnonymousId).mockReturnValue({
371
+ anonymousId: "test-anon-id-123",
372
+ isLoading: false,
373
+ });
374
+ });
375
+ });
376
+ });
@@ -1,13 +1,13 @@
1
- import { useState, useCallback, useMemo } from 'react';
2
- import { Platform } from 'react-native';
3
- import type { components } from '../types/index.js';
4
- import { useHarkenContext } from './useHarkenContext';
5
- import { useAnonymousId } from './useAnonymousId';
6
- import { HarkenClient } from '../api/client';
7
- import { HarkenApiError, HarkenNetworkError } from '../api/errors';
8
- import type { FeedbackCategory, DeviceMetadata } from '../types';
1
+ import { useState, useCallback, useMemo } from "react";
2
+ import { Platform } from "react-native";
3
+ import type { components } from "../types/index.js";
4
+ import { useHarkenContext } from "./useHarkenContext";
5
+ import { useAnonymousId } from "./useAnonymousId";
6
+ import { HarkenClient } from "../api/client";
7
+ import { HarkenApiError, HarkenNetworkError } from "../api/errors";
8
+ import type { FeedbackCategory, DeviceMetadata } from "../types";
9
9
 
10
- type FeedbackSubmissionResponse = components['schemas']['FeedbackSubmissionResponse'];
10
+ type FeedbackSubmissionResponse = components["schemas"]["FeedbackSubmissionResponse"];
11
11
 
12
12
  export interface SubmitFeedbackParams {
13
13
  /** Feedback message content */
@@ -82,7 +82,7 @@ export function useFeedback(): UseFeedbackResult {
82
82
  const submitFeedback = useCallback(
83
83
  async (params: SubmitFeedbackParams): Promise<FeedbackSubmissionResponse> => {
84
84
  if (!anonymousId) {
85
- throw new Error('Anonymous ID not yet initialized. Wait for isInitializing to be false.');
85
+ throw new Error("Anonymous ID not yet initialized. Wait for isInitializing to be false.");
86
86
  }
87
87
 
88
88
  setIsSubmitting(true);
@@ -93,9 +93,7 @@ export function useFeedback(): UseFeedbackResult {
93
93
  // Only set platform if it's a known value (ios, android)
94
94
  // Other platforms (web, windows, macos) should be passed via metadata
95
95
  const detectedPlatform =
96
- Platform.OS === 'ios' ? 'ios' :
97
- Platform.OS === 'android' ? 'android' :
98
- undefined;
96
+ Platform.OS === "ios" ? "ios" : Platform.OS === "android" ? "android" : undefined;
99
97
 
100
98
  const deviceMetadata: DeviceMetadata = {
101
99
  ...(detectedPlatform && { platform: detectedPlatform }),
@@ -117,7 +115,7 @@ export function useFeedback(): UseFeedbackResult {
117
115
  e instanceof HarkenApiError || e instanceof HarkenNetworkError
118
116
  ? e
119
117
  : new HarkenNetworkError(
120
- e instanceof Error ? e.message : 'Unknown error',
118
+ e instanceof Error ? e.message : "Unknown error",
121
119
  e instanceof Error ? e : undefined
122
120
  );
123
121
  setError(harkenError);
@@ -1,6 +1,6 @@
1
- import { useContext } from 'react';
2
- import { HarkenContext } from '../context';
3
- import type { HarkenContextValue } from '../context';
1
+ import { useContext } from "react";
2
+ import { HarkenContext } from "../context";
3
+ import type { HarkenContextValue } from "../context";
4
4
 
5
5
  /**
6
6
  * Hook to access the full Harken context.
@@ -28,7 +28,7 @@ export function useHarkenContext(): HarkenContextValue {
28
28
  const context = useContext(HarkenContext);
29
29
 
30
30
  if (!context) {
31
- throw new Error('useHarkenContext must be used within a HarkenProvider');
31
+ throw new Error("useHarkenContext must be used within a HarkenProvider");
32
32
  }
33
33
 
34
34
  return context;
@@ -1,13 +1,18 @@
1
- import { useContext } from 'react';
2
- import { HarkenContext } from '../context';
3
- import type { HarkenTheme } from '../theme';
1
+ import { useContext } from "react";
2
+ import { HarkenContext } from "../context";
3
+ import type { ResolvedHarkenTheme } from "../theme";
4
4
 
5
5
  /**
6
6
  * Hook to access the current Harken theme.
7
7
  *
8
8
  * Must be used within a HarkenProvider.
9
9
  *
10
- * @returns The resolved HarkenTheme object
10
+ * Returns a fully-resolved theme with all component tokens populated.
11
+ * You can access tokens in two ways:
12
+ * - Flat: `theme.colors.chipBackground`
13
+ * - Structured: `theme.components.chip.background`
14
+ *
15
+ * @returns The resolved theme object with all fallbacks applied
11
16
  * @throws Error if used outside of HarkenProvider
12
17
  *
13
18
  * @example
@@ -24,12 +29,31 @@ import type { HarkenTheme } from '../theme';
24
29
  * );
25
30
  * }
26
31
  * ```
32
+ *
33
+ * @example
34
+ * ```tsx
35
+ * // Using structured component tokens
36
+ * function ChipComponent() {
37
+ * const theme = useHarkenTheme();
38
+ * const { chip } = theme.components;
39
+ *
40
+ * return (
41
+ * <View style={{
42
+ * backgroundColor: chip.background,
43
+ * borderRadius: chip.radius,
44
+ * padding: chip.paddingVertical,
45
+ * }}>
46
+ * <Text style={{ color: chip.text }}>Label</Text>
47
+ * </View>
48
+ * );
49
+ * }
50
+ * ```
27
51
  */
28
- export function useHarkenTheme(): HarkenTheme {
52
+ export function useHarkenTheme(): ResolvedHarkenTheme {
29
53
  const context = useContext(HarkenContext);
30
54
 
31
55
  if (!context) {
32
- throw new Error('useHarkenTheme must be used within a HarkenProvider');
56
+ throw new Error("useHarkenTheme must be used within a HarkenProvider");
33
57
  }
34
58
 
35
59
  return context.theme;