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

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 (235) hide show
  1. package/README.md +67 -0
  2. package/app.plugin.cjs +135 -0
  3. package/app.plugin.js +1 -0
  4. package/dist/api/client.d.ts +67 -0
  5. package/dist/api/client.d.ts.map +1 -0
  6. package/dist/api/client.js +163 -0
  7. package/dist/api/client.js.map +1 -0
  8. package/dist/api/errors.d.ts +46 -0
  9. package/dist/api/errors.d.ts.map +1 -0
  10. package/dist/api/errors.js +72 -0
  11. package/dist/api/errors.js.map +1 -0
  12. package/dist/api/index.d.ts +7 -0
  13. package/dist/api/index.d.ts.map +1 -0
  14. package/dist/api/index.js +20 -0
  15. package/dist/api/index.js.map +1 -0
  16. package/dist/api/retry.d.ts +29 -0
  17. package/dist/api/retry.d.ts.map +1 -0
  18. package/dist/api/retry.js +74 -0
  19. package/dist/api/retry.js.map +1 -0
  20. package/dist/attachments/FeedbackSheet.d.ts +88 -0
  21. package/dist/attachments/FeedbackSheet.d.ts.map +1 -0
  22. package/dist/attachments/FeedbackSheet.js +250 -0
  23. package/dist/attachments/FeedbackSheet.js.map +1 -0
  24. package/dist/attachments/index.d.ts +20 -0
  25. package/dist/attachments/index.d.ts.map +1 -0
  26. package/dist/attachments/index.js +40 -0
  27. package/dist/attachments/index.js.map +1 -0
  28. package/dist/components/AttachmentGrid.d.ts +94 -0
  29. package/dist/components/AttachmentGrid.d.ts.map +1 -0
  30. package/dist/components/AttachmentGrid.js +132 -0
  31. package/dist/components/AttachmentGrid.js.map +1 -0
  32. package/dist/components/AttachmentPicker.d.ts +98 -0
  33. package/dist/components/AttachmentPicker.d.ts.map +1 -0
  34. package/dist/components/AttachmentPicker.js +297 -0
  35. package/dist/components/AttachmentPicker.js.map +1 -0
  36. package/dist/components/AttachmentPreview.d.ts +78 -0
  37. package/dist/components/AttachmentPreview.d.ts.map +1 -0
  38. package/dist/components/AttachmentPreview.js +133 -0
  39. package/dist/components/AttachmentPreview.js.map +1 -0
  40. package/dist/components/CategorySelector.d.ts +77 -0
  41. package/dist/components/CategorySelector.d.ts.map +1 -0
  42. package/dist/components/CategorySelector.js +117 -0
  43. package/dist/components/CategorySelector.js.map +1 -0
  44. package/dist/components/FeedbackForm.d.ts +50 -0
  45. package/dist/components/FeedbackForm.d.ts.map +1 -0
  46. package/dist/components/FeedbackForm.js +141 -0
  47. package/dist/components/FeedbackForm.js.map +1 -0
  48. package/dist/components/FeedbackSheet.d.ts +75 -0
  49. package/dist/components/FeedbackSheet.d.ts.map +1 -0
  50. package/dist/components/FeedbackSheet.js +215 -0
  51. package/dist/components/FeedbackSheet.js.map +1 -0
  52. package/dist/components/ThemedButton.d.ts +23 -0
  53. package/dist/components/ThemedButton.d.ts.map +1 -0
  54. package/dist/components/ThemedButton.js +77 -0
  55. package/dist/components/ThemedButton.js.map +1 -0
  56. package/dist/components/ThemedText.d.ts +16 -0
  57. package/dist/components/ThemedText.d.ts.map +1 -0
  58. package/dist/components/ThemedText.js +44 -0
  59. package/dist/components/ThemedText.js.map +1 -0
  60. package/dist/components/ThemedTextInput.d.ts +13 -0
  61. package/dist/components/ThemedTextInput.d.ts.map +1 -0
  62. package/dist/components/ThemedTextInput.js +76 -0
  63. package/dist/components/ThemedTextInput.js.map +1 -0
  64. package/dist/components/UploadStatusOverlay.d.ts +82 -0
  65. package/dist/components/UploadStatusOverlay.d.ts.map +1 -0
  66. package/dist/components/UploadStatusOverlay.js +319 -0
  67. package/dist/components/UploadStatusOverlay.js.map +1 -0
  68. package/dist/components/index.d.ts +19 -0
  69. package/dist/components/index.d.ts.map +1 -0
  70. package/dist/components/index.js +28 -0
  71. package/dist/components/index.js.map +1 -0
  72. package/dist/context/HarkenContext.d.ts +62 -0
  73. package/dist/context/HarkenContext.d.ts.map +1 -0
  74. package/dist/context/HarkenContext.js +128 -0
  75. package/dist/context/HarkenContext.js.map +1 -0
  76. package/dist/context/index.d.ts +3 -0
  77. package/dist/context/index.d.ts.map +1 -0
  78. package/dist/context/index.js +7 -0
  79. package/dist/context/index.js.map +1 -0
  80. package/dist/domain/index.d.ts +3 -0
  81. package/dist/domain/index.d.ts.map +1 -0
  82. package/dist/domain/index.js +7 -0
  83. package/dist/domain/index.js.map +1 -0
  84. package/dist/domain/upload-queue.d.ts +116 -0
  85. package/dist/domain/upload-queue.d.ts.map +1 -0
  86. package/dist/domain/upload-queue.js +34 -0
  87. package/dist/domain/upload-queue.js.map +1 -0
  88. package/dist/hooks/index.d.ts +6 -0
  89. package/dist/hooks/index.d.ts.map +1 -0
  90. package/dist/hooks/index.js +16 -0
  91. package/dist/hooks/index.js.map +1 -0
  92. package/dist/hooks/useAnonymousId.d.ts +28 -0
  93. package/dist/hooks/useAnonymousId.d.ts.map +1 -0
  94. package/dist/hooks/useAnonymousId.js +59 -0
  95. package/dist/hooks/useAnonymousId.js.map +1 -0
  96. package/dist/hooks/useAttachmentPicker.d.ts +84 -0
  97. package/dist/hooks/useAttachmentPicker.d.ts.map +1 -0
  98. package/dist/hooks/useAttachmentPicker.js +181 -0
  99. package/dist/hooks/useAttachmentPicker.js.map +1 -0
  100. package/dist/hooks/useAttachmentStatus.d.ts +51 -0
  101. package/dist/hooks/useAttachmentStatus.d.ts.map +1 -0
  102. package/dist/hooks/useAttachmentStatus.js +69 -0
  103. package/dist/hooks/useAttachmentStatus.js.map +1 -0
  104. package/dist/hooks/useAttachmentUpload.d.ts +101 -0
  105. package/dist/hooks/useAttachmentUpload.d.ts.map +1 -0
  106. package/dist/hooks/useAttachmentUpload.js +293 -0
  107. package/dist/hooks/useAttachmentUpload.js.map +1 -0
  108. package/dist/hooks/useFeedback.d.ts +55 -0
  109. package/dist/hooks/useFeedback.d.ts.map +1 -0
  110. package/dist/hooks/useFeedback.js +96 -0
  111. package/dist/hooks/useFeedback.js.map +1 -0
  112. package/dist/hooks/useHarkenContext.d.ts +25 -0
  113. package/dist/hooks/useHarkenContext.d.ts.map +1 -0
  114. package/dist/hooks/useHarkenContext.js +35 -0
  115. package/dist/hooks/useHarkenContext.js.map +1 -0
  116. package/dist/hooks/useHarkenTheme.d.ts +26 -0
  117. package/dist/hooks/useHarkenTheme.d.ts.map +1 -0
  118. package/dist/hooks/useHarkenTheme.js +36 -0
  119. package/dist/hooks/useHarkenTheme.js.map +1 -0
  120. package/dist/index.d.ts +49 -0
  121. package/dist/index.d.ts.map +1 -0
  122. package/dist/index.js +91 -0
  123. package/dist/index.js.map +1 -0
  124. package/dist/services/index.d.ts +4 -0
  125. package/dist/services/index.d.ts.map +1 -0
  126. package/dist/services/index.js +9 -0
  127. package/dist/services/index.js.map +1 -0
  128. package/dist/services/uploadQueueService.d.ts +193 -0
  129. package/dist/services/uploadQueueService.d.ts.map +1 -0
  130. package/dist/services/uploadQueueService.js +623 -0
  131. package/dist/services/uploadQueueService.js.map +1 -0
  132. package/dist/services/uploadQueueStorage.d.ts +30 -0
  133. package/dist/services/uploadQueueStorage.d.ts.map +1 -0
  134. package/dist/services/uploadQueueStorage.js +77 -0
  135. package/dist/services/uploadQueueStorage.js.map +1 -0
  136. package/dist/storage/IdentityStore.d.ts +38 -0
  137. package/dist/storage/IdentityStore.d.ts.map +1 -0
  138. package/dist/storage/IdentityStore.js +83 -0
  139. package/dist/storage/IdentityStore.js.map +1 -0
  140. package/dist/storage/SecureStoreAdapter.d.ts +28 -0
  141. package/dist/storage/SecureStoreAdapter.d.ts.map +1 -0
  142. package/dist/storage/SecureStoreAdapter.js +52 -0
  143. package/dist/storage/SecureStoreAdapter.js.map +1 -0
  144. package/dist/storage/defaultStorage.d.ts +20 -0
  145. package/dist/storage/defaultStorage.d.ts.map +1 -0
  146. package/dist/storage/defaultStorage.js +131 -0
  147. package/dist/storage/defaultStorage.js.map +1 -0
  148. package/dist/storage/index.d.ts +6 -0
  149. package/dist/storage/index.d.ts.map +1 -0
  150. package/dist/storage/index.js +13 -0
  151. package/dist/storage/index.js.map +1 -0
  152. package/dist/storage/types.d.ts +32 -0
  153. package/dist/storage/types.d.ts.map +1 -0
  154. package/dist/storage/types.js +11 -0
  155. package/dist/storage/types.js.map +1 -0
  156. package/dist/theme/defaults.d.ts +43 -0
  157. package/dist/theme/defaults.d.ts.map +1 -0
  158. package/dist/theme/defaults.js +128 -0
  159. package/dist/theme/defaults.js.map +1 -0
  160. package/dist/theme/index.d.ts +3 -0
  161. package/dist/theme/index.d.ts.map +1 -0
  162. package/dist/theme/index.js +14 -0
  163. package/dist/theme/index.js.map +1 -0
  164. package/dist/theme/types.d.ts +136 -0
  165. package/dist/theme/types.d.ts.map +1 -0
  166. package/dist/theme/types.js +3 -0
  167. package/dist/theme/types.js.map +1 -0
  168. package/dist/types/config.d.ts +100 -0
  169. package/dist/types/config.d.ts.map +1 -0
  170. package/dist/types/config.js +3 -0
  171. package/dist/types/config.js.map +1 -0
  172. package/dist/types/index.d.ts +3 -0
  173. package/dist/types/index.d.ts.map +1 -0
  174. package/dist/types/index.js +3 -0
  175. package/dist/types/index.js.map +1 -0
  176. package/dist/types/openapi.d.ts +601 -0
  177. package/dist/types/openapi.d.ts.map +1 -0
  178. package/dist/types/openapi.js +7 -0
  179. package/dist/types/openapi.js.map +1 -0
  180. package/dist/utils/index.d.ts +2 -0
  181. package/dist/utils/index.d.ts.map +1 -0
  182. package/dist/utils/index.js +6 -0
  183. package/dist/utils/index.js.map +1 -0
  184. package/dist/utils/uuid.d.ts +10 -0
  185. package/dist/utils/uuid.d.ts.map +1 -0
  186. package/dist/utils/uuid.js +60 -0
  187. package/dist/utils/uuid.js.map +1 -0
  188. package/package.json +124 -0
  189. package/src/@types/expo-file-system-legacy.d.ts +13 -0
  190. package/src/api/client.ts +250 -0
  191. package/src/api/errors.ts +84 -0
  192. package/src/api/index.ts +15 -0
  193. package/src/api/retry.ts +99 -0
  194. package/src/attachments/FeedbackSheet.tsx +400 -0
  195. package/src/attachments/index.ts +70 -0
  196. package/src/components/AttachmentGrid.tsx +247 -0
  197. package/src/components/AttachmentPicker.tsx +391 -0
  198. package/src/components/AttachmentPreview.tsx +210 -0
  199. package/src/components/CategorySelector.tsx +174 -0
  200. package/src/components/FeedbackForm.tsx +216 -0
  201. package/src/components/FeedbackSheet.tsx +321 -0
  202. package/src/components/ThemedButton.tsx +127 -0
  203. package/src/components/ThemedText.tsx +65 -0
  204. package/src/components/ThemedTextInput.tsx +65 -0
  205. package/src/components/UploadStatusOverlay.tsx +440 -0
  206. package/src/components/index.ts +39 -0
  207. package/src/context/HarkenContext.tsx +129 -0
  208. package/src/context/index.ts +2 -0
  209. package/src/domain/index.ts +12 -0
  210. package/src/domain/upload-queue.ts +131 -0
  211. package/src/hooks/index.ts +10 -0
  212. package/src/hooks/useAnonymousId.ts +68 -0
  213. package/src/hooks/useAttachmentPicker.ts +243 -0
  214. package/src/hooks/useAttachmentStatus.ts +86 -0
  215. package/src/hooks/useAttachmentUpload.ts +370 -0
  216. package/src/hooks/useFeedback.ts +139 -0
  217. package/src/hooks/useHarkenContext.ts +35 -0
  218. package/src/hooks/useHarkenTheme.ts +36 -0
  219. package/src/index.ts +168 -0
  220. package/src/services/index.ts +11 -0
  221. package/src/services/uploadQueueService.ts +727 -0
  222. package/src/services/uploadQueueStorage.ts +78 -0
  223. package/src/storage/IdentityStore.ts +89 -0
  224. package/src/storage/SecureStoreAdapter.ts +59 -0
  225. package/src/storage/defaultStorage.ts +109 -0
  226. package/src/storage/index.ts +5 -0
  227. package/src/storage/types.ts +34 -0
  228. package/src/theme/defaults.ts +151 -0
  229. package/src/theme/index.ts +23 -0
  230. package/src/theme/types.ts +157 -0
  231. package/src/types/config.ts +112 -0
  232. package/src/types/index.ts +10 -0
  233. package/src/types/openapi.ts +601 -0
  234. package/src/utils/index.ts +1 -0
  235. package/src/utils/uuid.ts +77 -0
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Hook for subscribing to a single attachment's upload status.
3
+ *
4
+ * Useful for components that display a single attachment with progress indicator.
5
+ */
6
+
7
+ import { useState, useEffect } from 'react';
8
+ import { uploadQueueService } from '../services';
9
+ import { UploadPhase, UploadProgress } from '../domain';
10
+
11
+ /**
12
+ * Status information for a single attachment.
13
+ */
14
+ export interface AttachmentStatus {
15
+ /** Current upload phase */
16
+ phase: UploadPhase;
17
+ /** Upload progress (0.0 - 1.0) */
18
+ progress: number;
19
+ /** Error message if failed */
20
+ error?: string;
21
+ }
22
+
23
+ /**
24
+ * Hook to subscribe to a single attachment's upload status.
25
+ *
26
+ * Returns null if the attachment is not found in the queue.
27
+ *
28
+ * @param attachmentId - Server-assigned attachment ID
29
+ * @returns Current status or null if not found
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * function AttachmentThumbnail({ attachmentId, uri }: Props) {
34
+ * const status = useAttachmentStatus(attachmentId);
35
+ *
36
+ * if (!status) return null;
37
+ *
38
+ * return (
39
+ * <View>
40
+ * <Image source={{ uri }} />
41
+ * {status.phase === 'uploading' && (
42
+ * <ProgressBar progress={status.progress} />
43
+ * )}
44
+ * {status.phase === 'failed' && (
45
+ * <Text>Error: {status.error}</Text>
46
+ * )}
47
+ * {status.phase === 'completed' && (
48
+ * <Icon name="checkmark" />
49
+ * )}
50
+ * </View>
51
+ * );
52
+ * }
53
+ * ```
54
+ */
55
+ export function useAttachmentStatus(
56
+ attachmentId: string
57
+ ): AttachmentStatus | null {
58
+ const [status, setStatus] = useState<AttachmentStatus | null>(() => {
59
+ // Initialize with current state from queue
60
+ const item = uploadQueueService.getItemByAttachmentId(attachmentId);
61
+ if (!item) return null;
62
+ return {
63
+ phase: item.phase,
64
+ progress: item.progress,
65
+ error: item.lastError,
66
+ };
67
+ });
68
+
69
+ useEffect(() => {
70
+ const unsubscribe = uploadQueueService.onProgress(
71
+ (progress: UploadProgress) => {
72
+ if (progress.attachmentId !== attachmentId) return;
73
+
74
+ setStatus({
75
+ phase: progress.phase,
76
+ progress: progress.progress,
77
+ error: progress.error,
78
+ });
79
+ }
80
+ );
81
+
82
+ return unsubscribe;
83
+ }, [attachmentId]);
84
+
85
+ return status;
86
+ }
@@ -0,0 +1,370 @@
1
+ /**
2
+ * Hook for managing attachment uploads.
3
+ *
4
+ * Provides methods for picking images/documents and tracking upload progress.
5
+ * Uploads happen in background via the singleton uploadQueueService.
6
+ */
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';
15
+
16
+ /**
17
+ * State for a single attachment.
18
+ */
19
+ export interface AttachmentState {
20
+ /** Server-assigned attachment ID */
21
+ attachmentId: string;
22
+ /** Local file URI for preview */
23
+ localUri: string;
24
+ /** Original filename */
25
+ fileName: string;
26
+ /** MIME type */
27
+ mimeType: string;
28
+ /** Current upload phase */
29
+ phase: UploadPhase;
30
+ /** Upload progress (0.0 - 1.0) */
31
+ progress: number;
32
+ /** Error message if failed */
33
+ error?: string;
34
+ }
35
+
36
+ /**
37
+ * Return type for useAttachmentUpload hook.
38
+ */
39
+ export interface UseAttachmentUploadResult {
40
+ /** All current attachments */
41
+ attachments: AttachmentState[];
42
+
43
+ /** Pick image from camera or library */
44
+ pickImage: (source: 'camera' | 'library') => Promise<AttachmentState | null>;
45
+
46
+ /** Pick document (images or PDFs) */
47
+ pickDocument: () => Promise<AttachmentState | null>;
48
+
49
+ /** Add attachment from existing local URI */
50
+ addAttachment: (params: {
51
+ uri: string;
52
+ mimeType: string;
53
+ fileName: string;
54
+ fileSize: number;
55
+ }) => Promise<AttachmentState>;
56
+
57
+ /** Retry a failed upload */
58
+ retryAttachment: (attachmentId: string) => Promise<void>;
59
+
60
+ /** Remove attachment (cancels if uploading) */
61
+ removeAttachment: (attachmentId: string) => Promise<void>;
62
+
63
+ /** Get all attachment IDs for feedback submission */
64
+ getAttachmentIds: () => string[];
65
+
66
+ /** True if any uploads are in progress */
67
+ hasActiveUploads: boolean;
68
+
69
+ /** Clear all completed attachments */
70
+ clearCompleted: () => void;
71
+
72
+ /** Clear all failed attachments */
73
+ clearFailed: () => void;
74
+ }
75
+
76
+ /**
77
+ * Hook for managing attachment uploads with background support.
78
+ *
79
+ * @example
80
+ * ```tsx
81
+ * function FeedbackForm() {
82
+ * const {
83
+ * attachments,
84
+ * pickImage,
85
+ * removeAttachment,
86
+ * getAttachmentIds,
87
+ * hasActiveUploads,
88
+ * } = useAttachmentUpload();
89
+ *
90
+ * const handleAddPhoto = async () => {
91
+ * await pickImage('library');
92
+ * };
93
+ *
94
+ * const handleSubmit = async () => {
95
+ * // Can submit even if uploads are still in progress!
96
+ * await submitFeedback({
97
+ * message: 'Bug report',
98
+ * attachmentIds: getAttachmentIds(),
99
+ * });
100
+ * };
101
+ *
102
+ * return (
103
+ * <View>
104
+ * {attachments.map(att => (
105
+ * <AttachmentPreview
106
+ * key={att.attachmentId}
107
+ * uri={att.localUri}
108
+ * progress={att.progress}
109
+ * phase={att.phase}
110
+ * />
111
+ * ))}
112
+ * <Button onPress={handleAddPhoto} title="Add Photo" />
113
+ * <Button onPress={handleSubmit} title="Submit" />
114
+ * </View>
115
+ * );
116
+ * }
117
+ * ```
118
+ */
119
+ export function useAttachmentUpload(): UseAttachmentUploadResult {
120
+ const { client, config } = useHarkenContext();
121
+ const [attachments, setAttachments] = useState<Map<string, AttachmentState>>(
122
+ new Map()
123
+ );
124
+
125
+ // Track which attachment IDs this hook instance is managing
126
+ const attachmentIdsRef = useRef<Set<string>>(new Set());
127
+
128
+ // Initialize upload queue service on first use
129
+ useEffect(() => {
130
+ if (!uploadQueueService.initialized) {
131
+ void uploadQueueService.initialize({
132
+ client,
133
+ debug: config.debug,
134
+ });
135
+ }
136
+ }, [client, config.debug]);
137
+
138
+ // Subscribe to progress updates from the queue service
139
+ useEffect(() => {
140
+ // Guard against service not being initialized
141
+ if (!uploadQueueService) {
142
+ return;
143
+ }
144
+
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;
162
+ });
163
+ }
164
+ );
165
+
166
+ return () => {
167
+ unsubProgress();
168
+ };
169
+ }, []);
170
+
171
+ /**
172
+ * Add an attachment from a local URI.
173
+ */
174
+ const addAttachment = useCallback(
175
+ async (params: {
176
+ uri: string;
177
+ mimeType: string;
178
+ fileName: string;
179
+ fileSize: number;
180
+ }): Promise<AttachmentState> => {
181
+ const { attachmentId } = await uploadQueueService.enqueue({
182
+ localUri: params.uri,
183
+ mimeType: params.mimeType,
184
+ fileName: params.fileName,
185
+ fileSize: params.fileSize,
186
+ });
187
+
188
+ const state: AttachmentState = {
189
+ attachmentId,
190
+ localUri: params.uri,
191
+ fileName: params.fileName,
192
+ mimeType: params.mimeType,
193
+ phase: UploadPhase.QUEUED,
194
+ progress: 0,
195
+ };
196
+
197
+ attachmentIdsRef.current.add(attachmentId);
198
+ setAttachments((prev) => new Map(prev).set(attachmentId, state));
199
+
200
+ return state;
201
+ },
202
+ []
203
+ );
204
+
205
+ /**
206
+ * Pick an image from camera or photo library.
207
+ */
208
+ const pickImage = useCallback(
209
+ async (source: 'camera' | 'library'): Promise<AttachmentState | null> => {
210
+ const options: ImagePicker.ImagePickerOptions = {
211
+ mediaTypes: ['images'],
212
+ quality: 0.8,
213
+ };
214
+
215
+ const result =
216
+ source === 'camera'
217
+ ? await ImagePicker.launchCameraAsync(options)
218
+ : await ImagePicker.launchImageLibraryAsync(options);
219
+
220
+ if (result.canceled || !result.assets[0]) {
221
+ return null;
222
+ }
223
+
224
+ const asset = result.assets[0];
225
+ const fileName = asset.fileName ?? `image_${Date.now()}.jpg`;
226
+ const mimeType = asset.mimeType ?? 'image/jpeg';
227
+
228
+ // Get file size - use asset.fileSize if available, otherwise query filesystem
229
+ let fileSize = asset.fileSize;
230
+ if (fileSize === undefined || fileSize === null) {
231
+ const fileInfo = await FileSystem.getInfoAsync(asset.uri);
232
+ fileSize = fileInfo.exists && fileInfo.size ? fileInfo.size : 0;
233
+ }
234
+
235
+ return addAttachment({
236
+ uri: asset.uri,
237
+ mimeType,
238
+ fileName,
239
+ fileSize,
240
+ });
241
+ },
242
+ [addAttachment]
243
+ );
244
+
245
+ /**
246
+ * Pick a document (images or PDFs).
247
+ */
248
+ const pickDocument = useCallback(async (): Promise<AttachmentState | null> => {
249
+ const result = await DocumentPicker.getDocumentAsync({
250
+ type: ['image/*', 'application/pdf'],
251
+ copyToCacheDirectory: true,
252
+ });
253
+
254
+ if (result.canceled || !result.assets[0]) {
255
+ return null;
256
+ }
257
+
258
+ const asset = result.assets[0];
259
+
260
+ // Get file size - use asset.size if available, otherwise query filesystem
261
+ let fileSize = asset.size;
262
+ if (fileSize === undefined || fileSize === null) {
263
+ const fileInfo = await FileSystem.getInfoAsync(asset.uri);
264
+ fileSize = fileInfo.exists && fileInfo.size ? fileInfo.size : 0;
265
+ }
266
+
267
+ return addAttachment({
268
+ uri: asset.uri,
269
+ mimeType: asset.mimeType ?? 'application/octet-stream',
270
+ fileName: asset.name,
271
+ fileSize,
272
+ });
273
+ }, [addAttachment]);
274
+
275
+ /**
276
+ * Retry a failed attachment upload.
277
+ */
278
+ const retryAttachment = useCallback(
279
+ async (attachmentId: string): Promise<void> => {
280
+ await uploadQueueService.retryItem(attachmentId);
281
+ },
282
+ []
283
+ );
284
+
285
+ /**
286
+ * Remove an attachment (cancels upload if in progress).
287
+ */
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
+ );
300
+
301
+ /**
302
+ * Get all attachment IDs for feedback submission.
303
+ */
304
+ const getAttachmentIds = useCallback((): string[] => {
305
+ return Array.from(attachments.values()).map((a) => a.attachmentId);
306
+ }, [attachments]);
307
+
308
+ /**
309
+ * Clear all completed attachments from both local state and queue service.
310
+ */
311
+ const clearCompleted = useCallback((): void => {
312
+ // Clear from queue service (persisted storage)
313
+ void uploadQueueService.clearCompleted();
314
+
315
+ // Clear from local state
316
+ setAttachments((prev) => {
317
+ const next = new Map<string, AttachmentState>();
318
+ for (const [id, att] of prev) {
319
+ if (att.phase !== UploadPhase.COMPLETED) {
320
+ next.set(id, att);
321
+ } else {
322
+ attachmentIdsRef.current.delete(id);
323
+ }
324
+ }
325
+ return next;
326
+ });
327
+ }, []);
328
+
329
+ /**
330
+ * Clear all failed attachments from both local state and queue service.
331
+ */
332
+ const clearFailed = useCallback((): void => {
333
+ // Clear from queue service (persisted storage)
334
+ void uploadQueueService.clearFailed();
335
+
336
+ // Clear from local state
337
+ setAttachments((prev) => {
338
+ const next = new Map<string, AttachmentState>();
339
+ for (const [id, att] of prev) {
340
+ if (att.phase !== UploadPhase.FAILED) {
341
+ next.set(id, att);
342
+ } else {
343
+ attachmentIdsRef.current.delete(id);
344
+ }
345
+ }
346
+ return next;
347
+ });
348
+ }, []);
349
+
350
+ // Compute whether any uploads are in progress
351
+ const hasActiveUploads = Array.from(attachments.values()).some(
352
+ (a) =>
353
+ a.phase === UploadPhase.QUEUED ||
354
+ a.phase === UploadPhase.UPLOADING ||
355
+ a.phase === UploadPhase.CONFIRMING
356
+ );
357
+
358
+ return {
359
+ attachments: Array.from(attachments.values()),
360
+ pickImage,
361
+ pickDocument,
362
+ addAttachment,
363
+ retryAttachment,
364
+ removeAttachment,
365
+ getAttachmentIds,
366
+ hasActiveUploads,
367
+ clearCompleted,
368
+ clearFailed,
369
+ };
370
+ }
@@ -0,0 +1,139 @@
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
+
10
+ type FeedbackSubmissionResponse = components['schemas']['FeedbackSubmissionResponse'];
11
+
12
+ export interface SubmitFeedbackParams {
13
+ /** Feedback message content */
14
+ message: string;
15
+ /** Feedback category */
16
+ category: FeedbackCategory;
17
+ /** Optional title/subject */
18
+ title?: string;
19
+ /** Additional device metadata (merged with auto-collected metadata) */
20
+ metadata?: Partial<DeviceMetadata>;
21
+ /** Attachment IDs from presigned uploads */
22
+ attachments?: string[];
23
+ }
24
+
25
+ export interface UseFeedbackResult {
26
+ /** Submit feedback to Harken */
27
+ submitFeedback: (params: SubmitFeedbackParams) => Promise<FeedbackSubmissionResponse>;
28
+ /** True while a submission is in progress */
29
+ isSubmitting: boolean;
30
+ /** Last error from submission attempt */
31
+ error: HarkenApiError | HarkenNetworkError | null;
32
+ /** Clear the error state */
33
+ clearError: () => void;
34
+ /** True if anonymous ID is still loading */
35
+ isInitializing: boolean;
36
+ }
37
+
38
+ /**
39
+ * Hook for submitting feedback through the Harken API.
40
+ *
41
+ * Automatically includes the anonymous ID and device metadata.
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * function FeedbackScreen() {
46
+ * const { submitFeedback, isSubmitting, error } = useFeedback();
47
+ *
48
+ * const handleSubmit = async () => {
49
+ * try {
50
+ * await submitFeedback({
51
+ * message: 'Great app!',
52
+ * category: 'idea',
53
+ * });
54
+ * // Success!
55
+ * } catch (e) {
56
+ * // Error is also available in `error` state
57
+ * }
58
+ * };
59
+ * }
60
+ * ```
61
+ */
62
+ export function useFeedback(): UseFeedbackResult {
63
+ const { config } = useHarkenContext();
64
+ const { anonymousId, isLoading: isInitializing } = useAnonymousId();
65
+
66
+ const [isSubmitting, setIsSubmitting] = useState(false);
67
+ const [error, setError] = useState<HarkenApiError | HarkenNetworkError | null>(null);
68
+
69
+ // Create client instance (memoized)
70
+ const client = useMemo(() => {
71
+ return new HarkenClient({
72
+ publishableKey: config.publishableKey,
73
+ userToken: config.userToken,
74
+ baseUrl: config.apiBaseUrl,
75
+ });
76
+ }, [config.publishableKey, config.userToken, config.apiBaseUrl]);
77
+
78
+ const clearError = useCallback(() => {
79
+ setError(null);
80
+ }, []);
81
+
82
+ const submitFeedback = useCallback(
83
+ async (params: SubmitFeedbackParams): Promise<FeedbackSubmissionResponse> => {
84
+ if (!anonymousId) {
85
+ throw new Error('Anonymous ID not yet initialized. Wait for isInitializing to be false.');
86
+ }
87
+
88
+ setIsSubmitting(true);
89
+ setError(null);
90
+
91
+ try {
92
+ // Collect device metadata
93
+ // Only set platform if it's a known value (ios, android)
94
+ // Other platforms (web, windows, macos) should be passed via metadata
95
+ const detectedPlatform =
96
+ Platform.OS === 'ios' ? 'ios' :
97
+ Platform.OS === 'android' ? 'android' :
98
+ undefined;
99
+
100
+ const deviceMetadata: DeviceMetadata = {
101
+ ...(detectedPlatform && { platform: detectedPlatform }),
102
+ ...params.metadata,
103
+ };
104
+
105
+ const response = await client.submitFeedback({
106
+ message: params.message,
107
+ category: params.category,
108
+ title: params.title,
109
+ anon_id: anonymousId,
110
+ metadata: deviceMetadata,
111
+ attachments: params.attachments,
112
+ });
113
+
114
+ return response;
115
+ } catch (e) {
116
+ const harkenError =
117
+ e instanceof HarkenApiError || e instanceof HarkenNetworkError
118
+ ? e
119
+ : new HarkenNetworkError(
120
+ e instanceof Error ? e.message : 'Unknown error',
121
+ e instanceof Error ? e : undefined
122
+ );
123
+ setError(harkenError);
124
+ throw harkenError;
125
+ } finally {
126
+ setIsSubmitting(false);
127
+ }
128
+ },
129
+ [anonymousId, client]
130
+ );
131
+
132
+ return {
133
+ submitFeedback,
134
+ isSubmitting,
135
+ error,
136
+ clearError,
137
+ isInitializing,
138
+ };
139
+ }
@@ -0,0 +1,35 @@
1
+ import { useContext } from 'react';
2
+ import { HarkenContext } from '../context';
3
+ import type { HarkenContextValue } from '../context';
4
+
5
+ /**
6
+ * Hook to access the full Harken context.
7
+ *
8
+ * Provides access to theme, config, and SDK state.
9
+ * Must be used within a HarkenProvider.
10
+ *
11
+ * @returns The full HarkenContextValue
12
+ * @throws Error if used outside of HarkenProvider
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * function MyComponent() {
17
+ * const { theme, isDarkMode, config } = useHarkenContext();
18
+ *
19
+ * return (
20
+ * <View>
21
+ * <Text>Dark mode: {isDarkMode ? 'on' : 'off'}</Text>
22
+ * </View>
23
+ * );
24
+ * }
25
+ * ```
26
+ */
27
+ export function useHarkenContext(): HarkenContextValue {
28
+ const context = useContext(HarkenContext);
29
+
30
+ if (!context) {
31
+ throw new Error('useHarkenContext must be used within a HarkenProvider');
32
+ }
33
+
34
+ return context;
35
+ }
@@ -0,0 +1,36 @@
1
+ import { useContext } from 'react';
2
+ import { HarkenContext } from '../context';
3
+ import type { HarkenTheme } from '../theme';
4
+
5
+ /**
6
+ * Hook to access the current Harken theme.
7
+ *
8
+ * Must be used within a HarkenProvider.
9
+ *
10
+ * @returns The resolved HarkenTheme object
11
+ * @throws Error if used outside of HarkenProvider
12
+ *
13
+ * @example
14
+ * ```tsx
15
+ * function MyComponent() {
16
+ * const theme = useHarkenTheme();
17
+ *
18
+ * return (
19
+ * <View style={{ backgroundColor: theme.colors.background }}>
20
+ * <Text style={{ color: theme.colors.text }}>
21
+ * Hello
22
+ * </Text>
23
+ * </View>
24
+ * );
25
+ * }
26
+ * ```
27
+ */
28
+ export function useHarkenTheme(): HarkenTheme {
29
+ const context = useContext(HarkenContext);
30
+
31
+ if (!context) {
32
+ throw new Error('useHarkenTheme must be used within a HarkenProvider');
33
+ }
34
+
35
+ return context.theme;
36
+ }