@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.
- package/README.md +67 -0
- package/app.plugin.cjs +135 -0
- package/app.plugin.js +1 -0
- package/dist/api/client.d.ts +67 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +163 -0
- package/dist/api/client.js.map +1 -0
- package/dist/api/errors.d.ts +46 -0
- package/dist/api/errors.d.ts.map +1 -0
- package/dist/api/errors.js +72 -0
- package/dist/api/errors.js.map +1 -0
- package/dist/api/index.d.ts +7 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +20 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/retry.d.ts +29 -0
- package/dist/api/retry.d.ts.map +1 -0
- package/dist/api/retry.js +74 -0
- package/dist/api/retry.js.map +1 -0
- package/dist/attachments/FeedbackSheet.d.ts +88 -0
- package/dist/attachments/FeedbackSheet.d.ts.map +1 -0
- package/dist/attachments/FeedbackSheet.js +250 -0
- package/dist/attachments/FeedbackSheet.js.map +1 -0
- package/dist/attachments/index.d.ts +20 -0
- package/dist/attachments/index.d.ts.map +1 -0
- package/dist/attachments/index.js +40 -0
- package/dist/attachments/index.js.map +1 -0
- package/dist/components/AttachmentGrid.d.ts +94 -0
- package/dist/components/AttachmentGrid.d.ts.map +1 -0
- package/dist/components/AttachmentGrid.js +132 -0
- package/dist/components/AttachmentGrid.js.map +1 -0
- package/dist/components/AttachmentPicker.d.ts +98 -0
- package/dist/components/AttachmentPicker.d.ts.map +1 -0
- package/dist/components/AttachmentPicker.js +297 -0
- package/dist/components/AttachmentPicker.js.map +1 -0
- package/dist/components/AttachmentPreview.d.ts +78 -0
- package/dist/components/AttachmentPreview.d.ts.map +1 -0
- package/dist/components/AttachmentPreview.js +133 -0
- package/dist/components/AttachmentPreview.js.map +1 -0
- package/dist/components/CategorySelector.d.ts +77 -0
- package/dist/components/CategorySelector.d.ts.map +1 -0
- package/dist/components/CategorySelector.js +117 -0
- package/dist/components/CategorySelector.js.map +1 -0
- package/dist/components/FeedbackForm.d.ts +50 -0
- package/dist/components/FeedbackForm.d.ts.map +1 -0
- package/dist/components/FeedbackForm.js +141 -0
- package/dist/components/FeedbackForm.js.map +1 -0
- package/dist/components/FeedbackSheet.d.ts +75 -0
- package/dist/components/FeedbackSheet.d.ts.map +1 -0
- package/dist/components/FeedbackSheet.js +215 -0
- package/dist/components/FeedbackSheet.js.map +1 -0
- package/dist/components/ThemedButton.d.ts +23 -0
- package/dist/components/ThemedButton.d.ts.map +1 -0
- package/dist/components/ThemedButton.js +77 -0
- package/dist/components/ThemedButton.js.map +1 -0
- package/dist/components/ThemedText.d.ts +16 -0
- package/dist/components/ThemedText.d.ts.map +1 -0
- package/dist/components/ThemedText.js +44 -0
- package/dist/components/ThemedText.js.map +1 -0
- package/dist/components/ThemedTextInput.d.ts +13 -0
- package/dist/components/ThemedTextInput.d.ts.map +1 -0
- package/dist/components/ThemedTextInput.js +76 -0
- package/dist/components/ThemedTextInput.js.map +1 -0
- package/dist/components/UploadStatusOverlay.d.ts +82 -0
- package/dist/components/UploadStatusOverlay.d.ts.map +1 -0
- package/dist/components/UploadStatusOverlay.js +319 -0
- package/dist/components/UploadStatusOverlay.js.map +1 -0
- package/dist/components/index.d.ts +19 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +28 -0
- package/dist/components/index.js.map +1 -0
- package/dist/context/HarkenContext.d.ts +62 -0
- package/dist/context/HarkenContext.d.ts.map +1 -0
- package/dist/context/HarkenContext.js +128 -0
- package/dist/context/HarkenContext.js.map +1 -0
- package/dist/context/index.d.ts +3 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +7 -0
- package/dist/context/index.js.map +1 -0
- package/dist/domain/index.d.ts +3 -0
- package/dist/domain/index.d.ts.map +1 -0
- package/dist/domain/index.js +7 -0
- package/dist/domain/index.js.map +1 -0
- package/dist/domain/upload-queue.d.ts +116 -0
- package/dist/domain/upload-queue.d.ts.map +1 -0
- package/dist/domain/upload-queue.js +34 -0
- package/dist/domain/upload-queue.js.map +1 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +16 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/useAnonymousId.d.ts +28 -0
- package/dist/hooks/useAnonymousId.d.ts.map +1 -0
- package/dist/hooks/useAnonymousId.js +59 -0
- package/dist/hooks/useAnonymousId.js.map +1 -0
- package/dist/hooks/useAttachmentPicker.d.ts +84 -0
- package/dist/hooks/useAttachmentPicker.d.ts.map +1 -0
- package/dist/hooks/useAttachmentPicker.js +181 -0
- package/dist/hooks/useAttachmentPicker.js.map +1 -0
- package/dist/hooks/useAttachmentStatus.d.ts +51 -0
- package/dist/hooks/useAttachmentStatus.d.ts.map +1 -0
- package/dist/hooks/useAttachmentStatus.js +69 -0
- package/dist/hooks/useAttachmentStatus.js.map +1 -0
- package/dist/hooks/useAttachmentUpload.d.ts +101 -0
- package/dist/hooks/useAttachmentUpload.d.ts.map +1 -0
- package/dist/hooks/useAttachmentUpload.js +293 -0
- package/dist/hooks/useAttachmentUpload.js.map +1 -0
- package/dist/hooks/useFeedback.d.ts +55 -0
- package/dist/hooks/useFeedback.d.ts.map +1 -0
- package/dist/hooks/useFeedback.js +96 -0
- package/dist/hooks/useFeedback.js.map +1 -0
- package/dist/hooks/useHarkenContext.d.ts +25 -0
- package/dist/hooks/useHarkenContext.d.ts.map +1 -0
- package/dist/hooks/useHarkenContext.js +35 -0
- package/dist/hooks/useHarkenContext.js.map +1 -0
- package/dist/hooks/useHarkenTheme.d.ts +26 -0
- package/dist/hooks/useHarkenTheme.d.ts.map +1 -0
- package/dist/hooks/useHarkenTheme.js +36 -0
- package/dist/hooks/useHarkenTheme.js.map +1 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +91 -0
- package/dist/index.js.map +1 -0
- package/dist/services/index.d.ts +4 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +9 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/uploadQueueService.d.ts +193 -0
- package/dist/services/uploadQueueService.d.ts.map +1 -0
- package/dist/services/uploadQueueService.js +623 -0
- package/dist/services/uploadQueueService.js.map +1 -0
- package/dist/services/uploadQueueStorage.d.ts +30 -0
- package/dist/services/uploadQueueStorage.d.ts.map +1 -0
- package/dist/services/uploadQueueStorage.js +77 -0
- package/dist/services/uploadQueueStorage.js.map +1 -0
- package/dist/storage/IdentityStore.d.ts +38 -0
- package/dist/storage/IdentityStore.d.ts.map +1 -0
- package/dist/storage/IdentityStore.js +83 -0
- package/dist/storage/IdentityStore.js.map +1 -0
- package/dist/storage/SecureStoreAdapter.d.ts +28 -0
- package/dist/storage/SecureStoreAdapter.d.ts.map +1 -0
- package/dist/storage/SecureStoreAdapter.js +52 -0
- package/dist/storage/SecureStoreAdapter.js.map +1 -0
- package/dist/storage/defaultStorage.d.ts +20 -0
- package/dist/storage/defaultStorage.d.ts.map +1 -0
- package/dist/storage/defaultStorage.js +131 -0
- package/dist/storage/defaultStorage.js.map +1 -0
- package/dist/storage/index.d.ts +6 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +13 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/types.d.ts +32 -0
- package/dist/storage/types.d.ts.map +1 -0
- package/dist/storage/types.js +11 -0
- package/dist/storage/types.js.map +1 -0
- package/dist/theme/defaults.d.ts +43 -0
- package/dist/theme/defaults.d.ts.map +1 -0
- package/dist/theme/defaults.js +128 -0
- package/dist/theme/defaults.js.map +1 -0
- package/dist/theme/index.d.ts +3 -0
- package/dist/theme/index.d.ts.map +1 -0
- package/dist/theme/index.js +14 -0
- package/dist/theme/index.js.map +1 -0
- package/dist/theme/types.d.ts +136 -0
- package/dist/theme/types.d.ts.map +1 -0
- package/dist/theme/types.js +3 -0
- package/dist/theme/types.js.map +1 -0
- package/dist/types/config.d.ts +100 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/config.js +3 -0
- package/dist/types/config.js.map +1 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/openapi.d.ts +601 -0
- package/dist/types/openapi.d.ts.map +1 -0
- package/dist/types/openapi.js +7 -0
- package/dist/types/openapi.js.map +1 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +6 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/uuid.d.ts +10 -0
- package/dist/utils/uuid.d.ts.map +1 -0
- package/dist/utils/uuid.js +60 -0
- package/dist/utils/uuid.js.map +1 -0
- package/package.json +124 -0
- package/src/@types/expo-file-system-legacy.d.ts +13 -0
- package/src/api/client.ts +250 -0
- package/src/api/errors.ts +84 -0
- package/src/api/index.ts +15 -0
- package/src/api/retry.ts +99 -0
- package/src/attachments/FeedbackSheet.tsx +400 -0
- package/src/attachments/index.ts +70 -0
- package/src/components/AttachmentGrid.tsx +247 -0
- package/src/components/AttachmentPicker.tsx +391 -0
- package/src/components/AttachmentPreview.tsx +210 -0
- package/src/components/CategorySelector.tsx +174 -0
- package/src/components/FeedbackForm.tsx +216 -0
- package/src/components/FeedbackSheet.tsx +321 -0
- package/src/components/ThemedButton.tsx +127 -0
- package/src/components/ThemedText.tsx +65 -0
- package/src/components/ThemedTextInput.tsx +65 -0
- package/src/components/UploadStatusOverlay.tsx +440 -0
- package/src/components/index.ts +39 -0
- package/src/context/HarkenContext.tsx +129 -0
- package/src/context/index.ts +2 -0
- package/src/domain/index.ts +12 -0
- package/src/domain/upload-queue.ts +131 -0
- package/src/hooks/index.ts +10 -0
- package/src/hooks/useAnonymousId.ts +68 -0
- package/src/hooks/useAttachmentPicker.ts +243 -0
- package/src/hooks/useAttachmentStatus.ts +86 -0
- package/src/hooks/useAttachmentUpload.ts +370 -0
- package/src/hooks/useFeedback.ts +139 -0
- package/src/hooks/useHarkenContext.ts +35 -0
- package/src/hooks/useHarkenTheme.ts +36 -0
- package/src/index.ts +168 -0
- package/src/services/index.ts +11 -0
- package/src/services/uploadQueueService.ts +727 -0
- package/src/services/uploadQueueStorage.ts +78 -0
- package/src/storage/IdentityStore.ts +89 -0
- package/src/storage/SecureStoreAdapter.ts +59 -0
- package/src/storage/defaultStorage.ts +109 -0
- package/src/storage/index.ts +5 -0
- package/src/storage/types.ts +34 -0
- package/src/theme/defaults.ts +151 -0
- package/src/theme/index.ts +23 -0
- package/src/theme/types.ts +157 -0
- package/src/types/config.ts +112 -0
- package/src/types/index.ts +10 -0
- package/src/types/openapi.ts +601 -0
- package/src/utils/index.ts +1 -0
- 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
|
+
}
|