@ankhorage/zora 5.0.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +14 -10
  3. package/dist/components/button/resolveButtonThemeRecipe.d.ts +1 -1
  4. package/dist/components/date-picker/DatePicker.d.ts.map +1 -1
  5. package/dist/components/date-picker/DatePicker.js +92 -86
  6. package/dist/components/date-picker/DatePicker.js.map +1 -1
  7. package/dist/components/date-picker/meta.d.ts +1 -1
  8. package/dist/components/date-picker/meta.js +1 -1
  9. package/dist/components/date-picker/meta.js.map +1 -1
  10. package/dist/components/heading/resolveHeadingThemeRecipe.d.ts +1 -1
  11. package/dist/components/image/Image.d.ts +1 -5
  12. package/dist/components/image/Image.d.ts.map +1 -1
  13. package/dist/components/image/Image.js +79 -8
  14. package/dist/components/image/Image.js.map +1 -1
  15. package/dist/components/image/types.d.ts +4 -2
  16. package/dist/components/image/types.d.ts.map +1 -1
  17. package/dist/components/image/types.js.map +1 -1
  18. package/dist/components/text/resolveTextThemeRecipe.d.ts +1 -1
  19. package/dist/components/time-picker/TimePicker.d.ts +1 -3
  20. package/dist/components/time-picker/TimePicker.d.ts.map +1 -1
  21. package/dist/components/time-picker/TimePicker.js +55 -32
  22. package/dist/components/time-picker/TimePicker.js.map +1 -1
  23. package/dist/components/time-picker/meta.d.ts +1 -1
  24. package/dist/components/time-picker/meta.js +1 -1
  25. package/dist/components/time-picker/meta.js.map +1 -1
  26. package/dist/features/bottom-sheet/public.d.ts +3 -0
  27. package/dist/features/bottom-sheet/public.d.ts.map +1 -0
  28. package/dist/features/bottom-sheet/public.js +2 -0
  29. package/dist/features/bottom-sheet/public.js.map +1 -0
  30. package/dist/features/uploader/adapters/inbound/Uploader.d.ts +5 -0
  31. package/dist/features/uploader/adapters/inbound/Uploader.d.ts.map +1 -0
  32. package/dist/features/uploader/adapters/inbound/Uploader.js +274 -0
  33. package/dist/features/uploader/adapters/inbound/Uploader.js.map +1 -0
  34. package/dist/features/uploader/adapters/outbound/pickDocumentOrFileAsync.d.ts +5 -0
  35. package/dist/features/uploader/adapters/outbound/pickDocumentOrFileAsync.d.ts.map +1 -0
  36. package/dist/features/uploader/adapters/outbound/pickDocumentOrFileAsync.js +38 -0
  37. package/dist/features/uploader/adapters/outbound/pickDocumentOrFileAsync.js.map +1 -0
  38. package/dist/features/uploader/adapters/outbound/pickImageOrVideoAsync.d.ts +5 -0
  39. package/dist/features/uploader/adapters/outbound/pickImageOrVideoAsync.d.ts.map +1 -0
  40. package/dist/features/uploader/adapters/outbound/pickImageOrVideoAsync.js +24 -0
  41. package/dist/features/uploader/adapters/outbound/pickImageOrVideoAsync.js.map +1 -0
  42. package/dist/features/uploader/application/ports/outbound/UploadPickerPort.d.ts +9 -0
  43. package/dist/features/uploader/application/ports/outbound/UploadPickerPort.d.ts.map +1 -0
  44. package/dist/features/uploader/application/ports/outbound/UploadPickerPort.js +2 -0
  45. package/dist/features/uploader/application/ports/outbound/UploadPickerPort.js.map +1 -0
  46. package/dist/features/uploader/application/use-cases/pickUploadAssetAsync.d.ts +5 -0
  47. package/dist/features/uploader/application/use-cases/pickUploadAssetAsync.d.ts.map +1 -0
  48. package/dist/features/uploader/application/use-cases/pickUploadAssetAsync.js +6 -0
  49. package/dist/features/uploader/application/use-cases/pickUploadAssetAsync.js.map +1 -0
  50. package/dist/features/uploader/application/use-cases/validateUploadAsset.d.ts +4 -0
  51. package/dist/features/uploader/application/use-cases/validateUploadAsset.d.ts.map +1 -0
  52. package/dist/features/uploader/application/use-cases/validateUploadAsset.js +77 -0
  53. package/dist/features/uploader/application/use-cases/validateUploadAsset.js.map +1 -0
  54. package/dist/features/uploader/composition/createUploadPicker.d.ts +4 -0
  55. package/dist/features/uploader/composition/createUploadPicker.d.ts.map +1 -0
  56. package/dist/features/uploader/composition/createUploadPicker.js +12 -0
  57. package/dist/features/uploader/composition/createUploadPicker.js.map +1 -0
  58. package/dist/features/uploader/meta.d.ts +10 -0
  59. package/dist/features/uploader/meta.d.ts.map +1 -0
  60. package/dist/features/uploader/meta.js +10 -0
  61. package/dist/features/uploader/meta.js.map +1 -0
  62. package/dist/features/uploader/public.d.ts +4 -0
  63. package/dist/features/uploader/public.d.ts.map +1 -0
  64. package/dist/features/uploader/public.js +3 -0
  65. package/dist/features/uploader/public.js.map +1 -0
  66. package/dist/index.d.ts +2 -6
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +1 -3
  69. package/dist/index.js.map +1 -1
  70. package/dist/metadata/componentMeta.d.ts.map +1 -1
  71. package/dist/metadata/componentMeta.js +2 -7
  72. package/dist/metadata/componentMeta.js.map +1 -1
  73. package/dist/registry.d.ts.map +1 -1
  74. package/dist/registry.js +2 -7
  75. package/dist/registry.js.map +1 -1
  76. package/dist/theme/ZoraProvider.d.ts +1 -1
  77. package/dist/theme/ZoraProvider.d.ts.map +1 -1
  78. package/dist/theme/ZoraProvider.js +3 -2
  79. package/dist/theme/ZoraProvider.js.map +1 -1
  80. package/dist/theme/useZoraTheme.d.ts +1 -1
  81. package/dist/types/upload.d.ts +53 -0
  82. package/dist/types/upload.d.ts.map +1 -0
  83. package/dist/types/upload.js +2 -0
  84. package/dist/types/upload.js.map +1 -0
  85. package/package.json +25 -3
  86. package/src/components/date-picker/DatePicker.tsx +197 -129
  87. package/src/components/date-picker/meta.ts +1 -1
  88. package/src/components/image/Image.tsx +136 -8
  89. package/src/components/image/types.ts +6 -2
  90. package/src/components/time-picker/TimePicker.tsx +147 -60
  91. package/src/components/time-picker/meta.ts +1 -1
  92. package/src/expo57Migration.test.ts +56 -0
  93. package/src/features/bottom-sheet/public.test.ts +39 -0
  94. package/src/features/bottom-sheet/public.ts +5 -0
  95. package/src/features/uploader/adapters/inbound/Uploader.tsx +403 -0
  96. package/src/features/uploader/adapters/outbound/pickDocumentOrFileAsync.ts +50 -0
  97. package/src/features/uploader/adapters/outbound/pickImageOrVideoAsync.ts +30 -0
  98. package/src/features/uploader/application/ports/outbound/UploadPickerPort.ts +10 -0
  99. package/src/features/uploader/application/use-cases/pickUploadAssetAsync.test.ts +48 -0
  100. package/src/features/uploader/application/use-cases/pickUploadAssetAsync.ts +14 -0
  101. package/src/features/uploader/application/use-cases/validateUploadAsset.test.ts +51 -0
  102. package/src/features/uploader/application/use-cases/validateUploadAsset.ts +111 -0
  103. package/src/features/uploader/composition/createUploadPicker.ts +14 -0
  104. package/src/features/uploader/meta.ts +11 -0
  105. package/src/features/uploader/public.test.ts +34 -0
  106. package/src/features/uploader/public.ts +10 -0
  107. package/src/index.ts +9 -14
  108. package/src/metadata/componentMeta.ts +2 -7
  109. package/src/platformAcceptance.test.ts +1 -1
  110. package/src/registry.test.ts +1 -1
  111. package/src/registry.ts +2 -7
  112. package/src/showcaseCoverage.test.ts +1 -2
  113. package/src/theme/ZoraProvider.tsx +3 -2
  114. package/src/types/upload.ts +62 -0
  115. package/dist/components/action-sheet/ActionSheet.d.ts +0 -7
  116. package/dist/components/action-sheet/ActionSheet.d.ts.map +0 -1
  117. package/dist/components/action-sheet/ActionSheet.js +0 -11
  118. package/dist/components/action-sheet/ActionSheet.js.map +0 -1
  119. package/dist/components/action-sheet/ActionSheetItem.d.ts +0 -7
  120. package/dist/components/action-sheet/ActionSheetItem.d.ts.map +0 -1
  121. package/dist/components/action-sheet/ActionSheetItem.js +0 -17
  122. package/dist/components/action-sheet/ActionSheetItem.js.map +0 -1
  123. package/dist/components/action-sheet/index.d.ts +0 -4
  124. package/dist/components/action-sheet/index.d.ts.map +0 -1
  125. package/dist/components/action-sheet/index.js +0 -3
  126. package/dist/components/action-sheet/index.js.map +0 -1
  127. package/dist/components/action-sheet/meta.d.ts +0 -17
  128. package/dist/components/action-sheet/meta.d.ts.map +0 -1
  129. package/dist/components/action-sheet/meta.js +0 -17
  130. package/dist/components/action-sheet/meta.js.map +0 -1
  131. package/dist/components/action-sheet/types.d.ts +0 -13
  132. package/dist/components/action-sheet/types.d.ts.map +0 -1
  133. package/dist/components/action-sheet/types.js +0 -2
  134. package/dist/components/action-sheet/types.js.map +0 -1
  135. package/dist/patterns/image-preview/ImagePreview.d.ts +0 -9
  136. package/dist/patterns/image-preview/ImagePreview.d.ts.map +0 -1
  137. package/dist/patterns/image-preview/ImagePreview.js +0 -46
  138. package/dist/patterns/image-preview/ImagePreview.js.map +0 -1
  139. package/dist/patterns/image-preview/index.d.ts +0 -3
  140. package/dist/patterns/image-preview/index.d.ts.map +0 -1
  141. package/dist/patterns/image-preview/index.js +0 -2
  142. package/dist/patterns/image-preview/index.js.map +0 -1
  143. package/dist/patterns/image-preview/meta.d.ts +0 -9
  144. package/dist/patterns/image-preview/meta.d.ts.map +0 -1
  145. package/dist/patterns/image-preview/meta.js +0 -9
  146. package/dist/patterns/image-preview/meta.js.map +0 -1
  147. package/dist/patterns/image-preview/types.d.ts +0 -36
  148. package/dist/patterns/image-preview/types.d.ts.map +0 -1
  149. package/dist/patterns/image-preview/types.js +0 -2
  150. package/dist/patterns/image-preview/types.js.map +0 -1
  151. package/dist/patterns/image-upload-field/ImageUploadField.d.ts +0 -9
  152. package/dist/patterns/image-upload-field/ImageUploadField.d.ts.map +0 -1
  153. package/dist/patterns/image-upload-field/ImageUploadField.js +0 -222
  154. package/dist/patterns/image-upload-field/ImageUploadField.js.map +0 -1
  155. package/dist/patterns/image-upload-field/index.d.ts +0 -3
  156. package/dist/patterns/image-upload-field/index.d.ts.map +0 -1
  157. package/dist/patterns/image-upload-field/index.js +0 -2
  158. package/dist/patterns/image-upload-field/index.js.map +0 -1
  159. package/dist/patterns/image-upload-field/meta.d.ts +0 -9
  160. package/dist/patterns/image-upload-field/meta.d.ts.map +0 -1
  161. package/dist/patterns/image-upload-field/meta.js +0 -9
  162. package/dist/patterns/image-upload-field/meta.js.map +0 -1
  163. package/dist/patterns/image-upload-field/types.d.ts +0 -35
  164. package/dist/patterns/image-upload-field/types.d.ts.map +0 -1
  165. package/dist/patterns/image-upload-field/types.js +0 -2
  166. package/dist/patterns/image-upload-field/types.js.map +0 -1
  167. package/dist/patterns/image-upload-field/uploadFlow.d.ts +0 -18
  168. package/dist/patterns/image-upload-field/uploadFlow.d.ts.map +0 -1
  169. package/dist/patterns/image-upload-field/uploadFlow.js +0 -106
  170. package/dist/patterns/image-upload-field/uploadFlow.js.map +0 -1
  171. package/src/components/action-sheet/ActionSheet.tsx +0 -14
  172. package/src/components/action-sheet/ActionSheetItem.tsx +0 -30
  173. package/src/components/action-sheet/index.ts +0 -3
  174. package/src/components/action-sheet/meta.ts +0 -19
  175. package/src/components/action-sheet/types.ts +0 -23
  176. package/src/patterns/image-preview/ImagePreview.tsx +0 -82
  177. package/src/patterns/image-preview/index.ts +0 -2
  178. package/src/patterns/image-preview/meta.ts +0 -10
  179. package/src/patterns/image-preview/types.ts +0 -41
  180. package/src/patterns/image-upload-field/ImageUploadField.tsx +0 -319
  181. package/src/patterns/image-upload-field/index.ts +0 -2
  182. package/src/patterns/image-upload-field/meta.ts +0 -10
  183. package/src/patterns/image-upload-field/types.ts +0 -41
  184. package/src/patterns/image-upload-field/uploadFlow.test.ts +0 -117
  185. package/src/patterns/image-upload-field/uploadFlow.ts +0 -145
@@ -0,0 +1,403 @@
1
+ import React from 'react';
2
+
3
+ import { Button } from '../../../../components/button';
4
+ import { Icon } from '../../../../components/icon';
5
+ import { Image } from '../../../../components/image';
6
+ import { Modal } from '../../../../components/modal';
7
+ import { Progress } from '../../../../components/progress';
8
+ import { Text } from '../../../../components/text';
9
+ import { Box, Stack } from '../../../../foundation';
10
+ import { FormField } from '../../../../patterns/form-field';
11
+ import { withZoraThemeScope } from '../../../../theme/withZoraThemeScope';
12
+ import type { UploadAsset, UploaderProps, UploadType } from '../../../../types/upload';
13
+ import { validateUploadAsset } from '../../application/use-cases/validateUploadAsset';
14
+ import { createUploadPicker } from '../../composition/createUploadPicker';
15
+
16
+ /*** Picks, validates, uploads, previews, and removes one generic file asset. */
17
+ export const Uploader = withZoraThemeScope(UploaderInner);
18
+
19
+ /*** Owns the interactive state for the generic uploader inbound adapter. */
20
+ function UploaderInner({
21
+ themeId: _themeId,
22
+ mode: _mode,
23
+ testID,
24
+ value,
25
+ onChange,
26
+ label,
27
+ description,
28
+ helperText,
29
+ errorText,
30
+ type = 'file',
31
+ accept,
32
+ maxSizeBytes,
33
+ required,
34
+ disabled = false,
35
+ readOnly = false,
36
+ validatePicked,
37
+ onUpload,
38
+ onRemove,
39
+ aspectRatio = 1,
40
+ interactionPolicy,
41
+ }: UploaderProps) {
42
+ const passive = interactionPolicy === 'passive';
43
+ const picker = React.useMemo(() => createUploadPicker(), []);
44
+ const [internalError, setInternalError] = React.useState<string | undefined>();
45
+ const [uploading, setUploading] = React.useState(false);
46
+ const [removing, setRemoving] = React.useState(false);
47
+ const [progress, setProgress] = React.useState<number | null>(null);
48
+ const [previewOpen, setPreviewOpen] = React.useState(false);
49
+ const mountedRef = React.useRef<boolean | null>(null);
50
+ const resolvedAccept = resolveUploadAccept(type, accept);
51
+ const actionsDisabled = disabled || readOnly;
52
+ const effectiveError = errorText ?? internalError;
53
+
54
+ React.useEffect(() => {
55
+ mountedRef.current = true;
56
+
57
+ return () => {
58
+ mountedRef.current = false;
59
+ };
60
+ }, []);
61
+
62
+ const isMounted = React.useCallback(() => mountedRef.current === true, []);
63
+
64
+ const setProgressSafe = React.useCallback(
65
+ (next: number | null) => {
66
+ if (!isMounted()) {
67
+ return;
68
+ }
69
+
70
+ setProgress(clampProgress(next));
71
+ },
72
+ [isMounted],
73
+ );
74
+
75
+ const handlePick = React.useCallback(async () => {
76
+ if (passive || actionsDisabled || uploading || removing) {
77
+ return;
78
+ }
79
+
80
+ setInternalError(undefined);
81
+
82
+ try {
83
+ const picked = await picker.pickAsync({ accept: resolvedAccept, type });
84
+ if (!picked || !isMounted()) {
85
+ return;
86
+ }
87
+
88
+ const validationError = validateUploadAsset({
89
+ accept: resolvedAccept,
90
+ asset: picked,
91
+ maxSizeBytes,
92
+ validate: validatePicked,
93
+ });
94
+ if (validationError) {
95
+ setInternalError(validationError);
96
+ return;
97
+ }
98
+
99
+ onChange(picked);
100
+ if (!onUpload) {
101
+ return;
102
+ }
103
+
104
+ setUploading(true);
105
+ setProgressSafe(0);
106
+
107
+ try {
108
+ const uploaded = await onUpload(picked, { setProgress: setProgressSafe });
109
+ if (!isMounted()) {
110
+ return;
111
+ }
112
+
113
+ onChange(uploaded);
114
+ setUploading(false);
115
+ setProgress(null);
116
+ } catch (error) {
117
+ if (!isMounted()) {
118
+ return;
119
+ }
120
+
121
+ setInternalError(formatUnknownError(error));
122
+ setUploading(false);
123
+ setProgress(null);
124
+ }
125
+ } catch (error) {
126
+ if (isMounted()) {
127
+ setInternalError(formatUnknownError(error));
128
+ }
129
+ }
130
+ }, [
131
+ actionsDisabled,
132
+ isMounted,
133
+ maxSizeBytes,
134
+ onChange,
135
+ onUpload,
136
+ passive,
137
+ picker,
138
+ removing,
139
+ resolvedAccept,
140
+ setProgressSafe,
141
+ type,
142
+ uploading,
143
+ validatePicked,
144
+ ]);
145
+
146
+ const handleRemove = React.useCallback(async () => {
147
+ if (passive || actionsDisabled || uploading || removing || !value) {
148
+ return;
149
+ }
150
+
151
+ setInternalError(undefined);
152
+ if (!onRemove) {
153
+ onChange(null);
154
+ return;
155
+ }
156
+
157
+ setRemoving(true);
158
+ try {
159
+ await onRemove(value);
160
+ if (isMounted()) {
161
+ onChange(null);
162
+ setRemoving(false);
163
+ }
164
+ } catch (error) {
165
+ if (isMounted()) {
166
+ setInternalError(formatUnknownError(error));
167
+ setRemoving(false);
168
+ }
169
+ }
170
+ }, [actionsDisabled, isMounted, onChange, onRemove, passive, removing, uploading, value]);
171
+
172
+ const canPreviewImage = type === 'image' && resolveRenderableAssetUrl(value) !== null;
173
+
174
+ return (
175
+ <>
176
+ <FormField
177
+ description={description}
178
+ disabled={disabled}
179
+ errorText={effectiveError}
180
+ helperText={helperText}
181
+ interactionPolicy={interactionPolicy}
182
+ invalid={Boolean(effectiveError)}
183
+ label={label}
184
+ readOnly={readOnly}
185
+ required={required}
186
+ testID={testID}
187
+ >
188
+ <Stack gap="m">
189
+ <UploadPresentation asset={value} aspectRatio={aspectRatio} type={type} />
190
+
191
+ {resolvedAccept || maxSizeBytes ? (
192
+ <Stack gap="xs">
193
+ {resolvedAccept ? (
194
+ <Text emphasis="muted" variant="caption">
195
+ Accepted: {resolvedAccept}
196
+ </Text>
197
+ ) : null}
198
+ {maxSizeBytes ? (
199
+ <Text emphasis="muted" variant="caption">
200
+ Max size: {formatBytes(maxSizeBytes)}
201
+ </Text>
202
+ ) : null}
203
+ </Stack>
204
+ ) : null}
205
+
206
+ {uploading ? (
207
+ <Stack gap="xs">
208
+ <Text emphasis="muted" variant="caption">
209
+ Uploading…
210
+ </Text>
211
+ {progress !== null ? <Progress max={1} value={progress} /> : null}
212
+ </Stack>
213
+ ) : null}
214
+
215
+ <Stack direction={{ base: 'column', md: 'row' }} gap="s">
216
+ <Button
217
+ disabled={actionsDisabled || uploading || removing}
218
+ interactionPolicy={interactionPolicy}
219
+ onPress={() => {
220
+ void handlePick();
221
+ }}
222
+ >
223
+ {value ? 'Replace file' : 'Select file'}
224
+ </Button>
225
+
226
+ {value ? (
227
+ <Button
228
+ color="danger"
229
+ disabled={actionsDisabled || uploading || removing}
230
+ interactionPolicy={interactionPolicy}
231
+ loading={removing}
232
+ variant="outline"
233
+ onPress={() => {
234
+ void handleRemove();
235
+ }}
236
+ >
237
+ Remove
238
+ </Button>
239
+ ) : null}
240
+
241
+ {canPreviewImage ? (
242
+ <Button
243
+ color="neutral"
244
+ interactionPolicy={interactionPolicy}
245
+ variant="soft"
246
+ onPress={() => setPreviewOpen(true)}
247
+ >
248
+ Preview
249
+ </Button>
250
+ ) : null}
251
+ </Stack>
252
+ </Stack>
253
+ </FormField>
254
+
255
+ {canPreviewImage ? (
256
+ <Modal
257
+ closeOnBackdrop
258
+ interactionPolicy={interactionPolicy}
259
+ onDismiss={() => setPreviewOpen(false)}
260
+ title="Image preview"
261
+ visible={previewOpen}
262
+ >
263
+ <Stack gap="m">
264
+ <Image aspectRatio={aspectRatio} fit="contain" source={value} />
265
+ <Stack direction="row" justify="flex-end">
266
+ <Button
267
+ color="neutral"
268
+ interactionPolicy={interactionPolicy}
269
+ variant="soft"
270
+ onPress={() => setPreviewOpen(false)}
271
+ >
272
+ Close
273
+ </Button>
274
+ </Stack>
275
+ </Stack>
276
+ </Modal>
277
+ ) : null}
278
+ </>
279
+ );
280
+ }
281
+
282
+ /*** Renders image previews or generic file metadata without exposing picker-specific assets. */
283
+ function UploadPresentation({
284
+ asset,
285
+ aspectRatio,
286
+ type,
287
+ }: {
288
+ asset: UploadAsset | null;
289
+ aspectRatio: number;
290
+ type: UploadType;
291
+ }) {
292
+ if (type === 'image') {
293
+ return <Image aspectRatio={aspectRatio} source={asset} />;
294
+ }
295
+
296
+ return (
297
+ <Box borderWidth={1} p="m" radius="m">
298
+ <Stack direction="row" gap="s">
299
+ <Icon name={resolveUploadIcon(type)} size={22} />
300
+ <Stack gap="xs">
301
+ <Text variant="label" weight="semiBold">
302
+ {asset?.fileName ?? 'No file selected'}
303
+ </Text>
304
+ {asset?.contentType ? (
305
+ <Text emphasis="muted" variant="caption">
306
+ {asset.contentType}
307
+ </Text>
308
+ ) : null}
309
+ </Stack>
310
+ </Stack>
311
+ </Box>
312
+ );
313
+ }
314
+
315
+ /*** Resolves the native picker default while allowing accept to remain the precise restriction. */
316
+ function resolveUploadAccept(type: UploadType, accept: string | undefined): string | undefined {
317
+ if (accept) {
318
+ return accept;
319
+ }
320
+
321
+ if (type === 'image') {
322
+ return 'image/*';
323
+ }
324
+
325
+ if (type === 'video') {
326
+ return 'video/*';
327
+ }
328
+
329
+ return undefined;
330
+ }
331
+
332
+ /*** Resolves an upload asset URL when it can be rendered directly. */
333
+ function resolveRenderableAssetUrl(asset: UploadAsset | null): string | null {
334
+ if (!asset) {
335
+ return null;
336
+ }
337
+
338
+ switch (asset.kind) {
339
+ case 'local':
340
+ return asset.uri;
341
+ case 'url':
342
+ return asset.url;
343
+ case 'storage':
344
+ return asset.publicUrl ?? null;
345
+ }
346
+ }
347
+
348
+ /*** Resolves a generic icon for the selected upload mode. */
349
+ function resolveUploadIcon(
350
+ type: UploadType,
351
+ ): 'attach-outline' | 'document-text-outline' | 'videocam-outline' {
352
+ switch (type) {
353
+ case 'video':
354
+ return 'videocam-outline';
355
+ case 'document':
356
+ return 'document-text-outline';
357
+ case 'file':
358
+ case 'image':
359
+ return 'attach-outline';
360
+ }
361
+ }
362
+
363
+ /*** Clamps progress callbacks into the normalized 0..1 range. */
364
+ function clampProgress(value: number | null): number | null {
365
+ if (value === null || !Number.isFinite(value)) {
366
+ return null;
367
+ }
368
+
369
+ return Math.max(0, Math.min(1, value));
370
+ }
371
+
372
+ /*** Converts unknown picker/upload failures into user-facing text. */
373
+ function formatUnknownError(error: unknown): string {
374
+ if (error instanceof Error && error.message) {
375
+ return error.message;
376
+ }
377
+
378
+ if (typeof error === 'string' && error.trim().length > 0) {
379
+ return error;
380
+ }
381
+
382
+ return 'Something went wrong.';
383
+ }
384
+
385
+ /*** Formats an upload byte limit for concise helper text. */
386
+ function formatBytes(value: number): string {
387
+ if (!Number.isFinite(value) || value <= 0) {
388
+ return '0 B';
389
+ }
390
+
391
+ const units = ['B', 'KB', 'MB', 'GB'] as const;
392
+ let size = value;
393
+ let unitIndex = 0;
394
+
395
+ while (size >= 1024 && unitIndex < units.length - 1) {
396
+ size /= 1024;
397
+ unitIndex += 1;
398
+ }
399
+
400
+ const unit = units.at(unitIndex) ?? 'B';
401
+ const rounded = unitIndex === 0 ? Math.round(size) : Math.round(size * 10) / 10;
402
+ return `${rounded} ${unit}`;
403
+ }
@@ -0,0 +1,50 @@
1
+ import * as DocumentPicker from 'expo-document-picker';
2
+
3
+ import type { UploadAsset } from '../../../../types/upload';
4
+ import type { UploadPickerInput } from '../../application/ports/outbound/UploadPickerPort';
5
+
6
+ /*** Picks one document through Expo DocumentPicker and maps it to the ZORA upload contract. */
7
+ export async function pickDocumentOrFileAsync(
8
+ input: UploadPickerInput,
9
+ ): Promise<UploadAsset | null> {
10
+ const result = await DocumentPicker.getDocumentAsync({
11
+ copyToCacheDirectory: true,
12
+ multiple: false,
13
+ type: resolveDocumentMimeTypes(input.accept),
14
+ });
15
+
16
+ if (result.canceled) {
17
+ return null;
18
+ }
19
+
20
+ const asset = result.assets.at(0);
21
+ if (!asset) {
22
+ return null;
23
+ }
24
+
25
+ return {
26
+ kind: 'local',
27
+ uri: asset.uri,
28
+ fileName: asset.name,
29
+ sizeBytes: asset.size,
30
+ contentType: asset.mimeType,
31
+ };
32
+ }
33
+
34
+ /*** Maps browser-style accept MIME entries into Expo DocumentPicker's type option. */
35
+ function resolveDocumentMimeTypes(accept: string | undefined): string | string[] {
36
+ if (!accept) {
37
+ return '*/*';
38
+ }
39
+
40
+ const mimeTypes = accept
41
+ .split(',')
42
+ .map((token) => token.trim())
43
+ .filter((token) => token.includes('/'));
44
+
45
+ if (mimeTypes.length === 0) {
46
+ return '*/*';
47
+ }
48
+
49
+ return mimeTypes.length === 1 ? (mimeTypes.at(0) ?? '*/*') : mimeTypes;
50
+ }
@@ -0,0 +1,30 @@
1
+ import * as ImagePicker from 'expo-image-picker';
2
+
3
+ import type { UploadAsset } from '../../../../types/upload';
4
+ import type { UploadPickerInput } from '../../application/ports/outbound/UploadPickerPort';
5
+
6
+ /*** Picks one image or video through Expo ImagePicker and maps it to the ZORA upload contract. */
7
+ export async function pickImageOrVideoAsync(input: UploadPickerInput): Promise<UploadAsset | null> {
8
+ const result = await ImagePicker.launchImageLibraryAsync({
9
+ mediaTypes: input.type === 'video' ? ['videos'] : ['images'],
10
+ });
11
+
12
+ if (result.canceled) {
13
+ return null;
14
+ }
15
+
16
+ const asset = result.assets.at(0);
17
+ if (!asset) {
18
+ return null;
19
+ }
20
+
21
+ return {
22
+ kind: 'local',
23
+ uri: asset.uri,
24
+ fileName: asset.fileName ?? undefined,
25
+ sizeBytes: asset.fileSize,
26
+ contentType: asset.mimeType ?? undefined,
27
+ width: asset.width > 0 ? asset.width : undefined,
28
+ height: asset.height > 0 ? asset.height : undefined,
29
+ };
30
+ }
@@ -0,0 +1,10 @@
1
+ import type { UploadAsset, UploadType } from '../../../../../types/upload';
2
+
3
+ export interface UploadPickerInput {
4
+ type: UploadType;
5
+ accept?: string;
6
+ }
7
+
8
+ export interface UploadPickerPort {
9
+ pickAsync: (input: UploadPickerInput) => Promise<UploadAsset | null>;
10
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import type { UploadPickerPort } from '../ports/outbound/UploadPickerPort';
4
+ import { pickUploadAssetAsync } from './pickUploadAssetAsync';
5
+
6
+ describe('pickUploadAssetAsync', () => {
7
+ test('routes image and video selection to the media picker', async () => {
8
+ const calls: string[] = [];
9
+ const mediaPicker: UploadPickerPort = {
10
+ pickAsync: (input) => {
11
+ calls.push(`media:${input.type}`);
12
+ return Promise.resolve(null);
13
+ },
14
+ };
15
+ const documentPicker: UploadPickerPort = {
16
+ pickAsync: (input) => {
17
+ calls.push(`document:${input.type}`);
18
+ return Promise.resolve(null);
19
+ },
20
+ };
21
+
22
+ await pickUploadAssetAsync({ type: 'image' }, mediaPicker, documentPicker);
23
+ await pickUploadAssetAsync({ type: 'video' }, mediaPicker, documentPicker);
24
+
25
+ expect(calls).toEqual(['media:image', 'media:video']);
26
+ });
27
+
28
+ test('routes document and generic file selection to the document picker', async () => {
29
+ const calls: string[] = [];
30
+ const mediaPicker: UploadPickerPort = {
31
+ pickAsync: (input) => {
32
+ calls.push(`media:${input.type}`);
33
+ return Promise.resolve(null);
34
+ },
35
+ };
36
+ const documentPicker: UploadPickerPort = {
37
+ pickAsync: (input) => {
38
+ calls.push(`document:${input.type}`);
39
+ return Promise.resolve(null);
40
+ },
41
+ };
42
+
43
+ await pickUploadAssetAsync({ type: 'document' }, mediaPicker, documentPicker);
44
+ await pickUploadAssetAsync({ type: 'file' }, mediaPicker, documentPicker);
45
+
46
+ expect(calls).toEqual(['document:document', 'document:file']);
47
+ });
48
+ });
@@ -0,0 +1,14 @@
1
+ import type { UploadAsset } from '../../../../types/upload';
2
+ import type { UploadPickerInput, UploadPickerPort } from '../ports/outbound/UploadPickerPort';
3
+
4
+ /*** Selects the appropriate picker capability for the requested upload type. */
5
+ export async function pickUploadAssetAsync(
6
+ input: UploadPickerInput,
7
+ imageOrVideoPicker: UploadPickerPort,
8
+ documentOrFilePicker: UploadPickerPort,
9
+ ): Promise<UploadAsset | null> {
10
+ const picker =
11
+ input.type === 'image' || input.type === 'video' ? imageOrVideoPicker : documentOrFilePicker;
12
+
13
+ return picker.pickAsync(input);
14
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { validateUploadAsset } from './validateUploadAsset';
4
+
5
+ const IMAGE_ASSET = {
6
+ kind: 'local',
7
+ uri: 'file://photo.png',
8
+ fileName: 'photo.png',
9
+ contentType: 'image/png',
10
+ sizeBytes: 2_000,
11
+ } as const;
12
+
13
+ describe('validateUploadAsset', () => {
14
+ test('accepts wildcard MIME types', () => {
15
+ expect(validateUploadAsset({ accept: 'image/*', asset: IMAGE_ASSET })).toBeUndefined();
16
+ });
17
+
18
+ test('rejects a mismatched MIME type', () => {
19
+ expect(validateUploadAsset({ accept: 'application/json', asset: IMAGE_ASSET })).toContain(
20
+ 'File type not accepted',
21
+ );
22
+ });
23
+
24
+ test('accepts matching file extensions', () => {
25
+ expect(validateUploadAsset({ accept: '.png', asset: IMAGE_ASSET })).toBeUndefined();
26
+ });
27
+
28
+ test('does not reject when the asset lacks metadata needed for validation', () => {
29
+ expect(
30
+ validateUploadAsset({
31
+ accept: 'image/*',
32
+ asset: { kind: 'local', uri: 'file://unknown' },
33
+ }),
34
+ ).toBeUndefined();
35
+ });
36
+
37
+ test('rejects assets above the configured size', () => {
38
+ expect(validateUploadAsset({ asset: IMAGE_ASSET, maxSizeBytes: 1_000 })).toContain(
39
+ 'File is too large',
40
+ );
41
+ });
42
+
43
+ test('runs caller validation after built-in validation', () => {
44
+ expect(
45
+ validateUploadAsset({
46
+ asset: IMAGE_ASSET,
47
+ validate: () => 'Consumer validation failed.',
48
+ }),
49
+ ).toBe('Consumer validation failed.');
50
+ });
51
+ });