@myazahq/kyc-sdk-react-native 2.6.0 → 3.1.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 (104) hide show
  1. package/README.md +128 -4
  2. package/android/build.gradle +30 -16
  3. package/android/consumer-rules.pro +22 -0
  4. package/android/src/main/AndroidManifest.xml +17 -6
  5. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +27 -72
  6. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +14 -0
  7. package/android/src/main/java/com/margelo/nitro/myazakyc/MlKitModelReadiness.kt +154 -0
  8. package/app.plugin.js +57 -2
  9. package/ios/HybridMyazaTextRecognizer.swift +8 -0
  10. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +21 -0
  11. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +2 -0
  12. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +8 -0
  13. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +16 -0
  14. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +2 -0
  15. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +31 -0
  16. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +2 -0
  17. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +2 -0
  18. package/package.json +24 -14
  19. package/src/MyazaKYC.tsx +4 -11
  20. package/src/assets/fonts/Karla_400Regular.ttf +0 -0
  21. package/src/assets/fonts/Karla_500Medium.ttf +0 -0
  22. package/src/assets/fonts/Karla_600SemiBold.ttf +0 -0
  23. package/src/assets/fonts/Karla_700Bold.ttf +0 -0
  24. package/src/assets/fonts/OFL-Karla.txt +93 -0
  25. package/src/assets/fonts/OFL-SpaceGrotesk.txt +93 -0
  26. package/src/assets/fonts/README.md +12 -0
  27. package/src/assets/fonts/SpaceGrotesk_500Medium.ttf +0 -0
  28. package/src/assets/fonts/SpaceGrotesk_600SemiBold.ttf +0 -0
  29. package/src/assets/fonts/SpaceGrotesk_700Bold.ttf +0 -0
  30. package/src/capture/useAutoCapture.ts +8 -1
  31. package/src/components/DocumentCropper.tsx +30 -24
  32. package/src/components/DocumentReview.tsx +11 -1
  33. package/src/components/DocumentReviewSide.tsx +6 -2
  34. package/src/components/DocumentReviewZoom.tsx +5 -1
  35. package/src/components/GlassIconButton.tsx +2 -5
  36. package/src/components/Icon.tsx +17 -13
  37. package/src/components/KycFlow.tsx +10 -2
  38. package/src/components/KycSheet.tsx +0 -1
  39. package/src/components/MyazaButton.tsx +9 -0
  40. package/src/components/RequiredDocumentPill.tsx +114 -0
  41. package/src/components/StepHeader.tsx +23 -3
  42. package/src/components/StepView.tsx +3 -0
  43. package/src/components/documentReviewCopy.ts +39 -0
  44. package/src/components/fonts.ts +14 -17
  45. package/src/components/icons/glyphs.ts +35 -0
  46. package/src/components/icons/index.ts +3 -0
  47. package/src/components/icons/map.ts +165 -0
  48. package/src/components/icons/names.ts +86 -0
  49. package/src/components/stepHeaderMeta.tsx +21 -1
  50. package/src/config/documentCaptureMethods.ts +31 -0
  51. package/src/config/stepOrder.ts +20 -5
  52. package/src/config/supportingDocuments.ts +131 -0
  53. package/src/config/theme.ts +29 -3
  54. package/src/config/workflowMerge.ts +15 -0
  55. package/src/index.ts +5 -10
  56. package/src/lib/documentCaptureCheck.ts +136 -0
  57. package/src/lib/model-ready.ts +82 -0
  58. package/src/lib/prime-models.ts +50 -0
  59. package/src/lib/resubmit.ts +36 -7
  60. package/src/lib/selfie-sharpness.ts +211 -0
  61. package/src/lib/supportingDocumentsIntro.ts +35 -0
  62. package/src/liveness/avatarSource.ts +59 -0
  63. package/src/liveness/useModelReady.ts +9 -55
  64. package/src/mrz/extract.ts +71 -4
  65. package/src/mrz/textRecognizer.ts +36 -0
  66. package/src/mrz/useTextModelReady.ts +21 -0
  67. package/src/screens/BusinessDocumentSlot.tsx +37 -8
  68. package/src/screens/DocumentCaptureStep.tsx +177 -93
  69. package/src/screens/LivenessAvatar.tsx +18 -13
  70. package/src/screens/LivenessStep.tsx +37 -0
  71. package/src/screens/MrzScanView.tsx +33 -0
  72. package/src/screens/SupportingDocumentCard.tsx +134 -0
  73. package/src/screens/SupportingDocumentsStep.tsx +203 -0
  74. package/src/screens/consent/model.ts +22 -5
  75. package/src/screens/document/CaptureCheckNotice.tsx +77 -0
  76. package/src/screens/document/UploadPhase.tsx +177 -0
  77. package/src/screens/document/useDocumentCamera.ts +109 -0
  78. package/src/screens/liveness/CaptureRing.tsx +31 -17
  79. package/src/screens/liveness/LivenessOutcome.tsx +4 -1
  80. package/src/screens/liveness/SelfiePreview.tsx +19 -4
  81. package/src/screens/supportingDocumentParts.tsx +126 -0
  82. package/src/screens/useBusinessDocumentAttach.ts +20 -9
  83. package/src/services/api-types.ts +31 -2
  84. package/src/services/api.ts +19 -3
  85. package/src/services/deviceMetadata.ts +1 -1
  86. package/src/services/mediaCompress.ts +23 -5
  87. package/src/specs/MyazaTextRecognizer.nitro.ts +29 -0
  88. package/src/store/derive.ts +11 -0
  89. package/src/store/kycStore.ts +23 -2
  90. package/src/store/session.ts +18 -0
  91. package/src/store/state.ts +26 -0
  92. package/src/store/submit.ts +11 -0
  93. package/src/types/config.ts +25 -0
  94. package/src/types/workflow.ts +39 -0
  95. package/src/MyazaBiometricAuth.tsx +0 -119
  96. package/src/assets/liveness/Blink.gif +0 -0
  97. package/src/assets/liveness/Nod.gif +0 -0
  98. package/src/assets/liveness/Smile.gif +0 -0
  99. package/src/assets/liveness/Turn.gif +0 -0
  100. package/src/components/icon-map.ts +0 -176
  101. package/src/lib/biometric-auth.ts +0 -52
  102. package/src/screens/biometric/BiometricAuthFlow.tsx +0 -170
  103. package/src/services/api-biometric.ts +0 -37
  104. package/src/services/api-types-biometric.ts +0 -41
@@ -0,0 +1,59 @@
1
+ import { Image } from 'react-native';
2
+
3
+ import { resolveBaseUrl } from '../services/resolveUrl';
4
+ import type { LivenessChallenge } from './types';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Where the gesture animations come from.
8
+ //
9
+ // They used to be four GIFs bundled in this package — 5.5 MB, shipped to every
10
+ // device whatever the workflow asked for, for a badge that is on screen for a
11
+ // few seconds of the liveness step. They are served by the API instead
12
+ // (`/api/kyc/assets/liveness/<gesture>.gif`), prefetched when the flow opens,
13
+ // and cached by the platform image loader from then on.
14
+ //
15
+ // GIF on BOTH platforms deliberately, even though the server also has WebP at a
16
+ // tenth of the size: React Native decodes only GIF animation on iOS, and
17
+ // animated WebP on Android needs Fresco's `animated-webp` module, which the
18
+ // host app enables or does not — invisible from in here. One URL, no platform
19
+ // branch, no dependence on the integrator's Fresco configuration.
20
+ //
21
+ // Nothing here throws and nothing here is awaited by the flow. A device that
22
+ // never gets the file shows the gesture icon the avatar has always fallen back
23
+ // to, and the instruction text above it says what to do regardless.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export function livenessAvatarUrl(
27
+ challenge: LivenessChallenge,
28
+ apiKey: string,
29
+ devUrl?: string,
30
+ ): string | null {
31
+ try {
32
+ // Throws on a malformed key, which is a real error everywhere else in the
33
+ // SDK and merely a missing cartoon here.
34
+ return `${resolveBaseUrl(apiKey, devUrl)}/api/kyc/assets/liveness/${challenge}.gif`;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ const GESTURES: readonly LivenessChallenge[] = ['nod', 'turn', 'blink', 'smile'];
41
+
42
+ /**
43
+ * Warm the image cache at flow open, so the avatar is already on the device by
44
+ * the time liveness renders.
45
+ *
46
+ * Called beside `primeFaceModel()` and for the same reason: the work overlaps
47
+ * consent and ID-type selection instead of stalling in front of the camera.
48
+ * All four are fetched because which gestures a session asks for is randomised
49
+ * per session, and together they are under 700 KB.
50
+ */
51
+ export function primeLivenessAvatars(apiKey: string, devUrl?: string): void {
52
+ for (const gesture of GESTURES) {
53
+ const url = livenessAvatarUrl(gesture, apiKey, devUrl);
54
+ if (url == null) return;
55
+ Image.prefetch(url).catch(() => {
56
+ /* best-effort: the avatar falls back to its icon */
57
+ });
58
+ }
59
+ }
@@ -1,65 +1,19 @@
1
- import { useEffect, useRef, useState } from 'react';
2
-
1
+ import { useNativeModelReady, type ModelReadyState } from '../lib/model-ready';
3
2
  import { isFaceModelReady, primeFaceModel } from './visionCameraFaceDetector';
4
3
 
5
- // ─── Face-model readiness gate ────────────────────────────────────────────────
6
- //
7
- // Android fetches ML Kit's face model through Play Services rather than bundling
8
- // it, which keeps ~8 MB per device out of the APK but leaves a window where
9
- // detection cannot run: first launch before the download lands, or a device with
10
- // no Google Play Services at all.
11
- //
12
- // This has to be answered BEFORE the camera opens. `detectFace` reports through
13
- // `FaceResult`, where a missing model and an empty frame are both
14
- // `faceCount: 0` — so gating on the per-frame result would strand the user on
15
- // "position your face" indefinitely, with the SDK unable to say why. That is the
16
- // single failure mode this whole gate exists to prevent.
17
- //
18
- // iOS is always ready (Apple Vision is a system framework), so this resolves on
19
- // the first tick there and costs nothing.
20
-
21
- export type ModelReadyState = 'ready' | 'preparing' | 'unavailable';
4
+ // The face half of the shared readiness gate. The rule, the polling and the
5
+ // reasoning live in ../lib/model-ready.ts, because the text recogniser needs
6
+ // exactly the same thing and one decision should not exist in two places.
22
7
 
23
- /** How long to wait for the model before calling it unavailable. */
24
- const MODEL_WAIT_MS = 20_000;
25
- /** Gap between readiness polls while the download is in flight. */
26
- const POLL_INTERVAL_MS = 500;
8
+ export type { ModelReadyState };
27
9
 
28
10
  /**
29
11
  * Tracks whether on-device face detection can run.
30
12
  *
31
- * Returns `'preparing'` while Play Services fetches the model, `'ready'` once it
32
- * can run, and `'unavailable'` when it could not be obtained within
33
- * {@link MODEL_WAIT_MS} — no Play Services, no network, or a declined install.
34
- *
35
- * The flow primes the download at open (see `MyazaKYC.tsx`), so by the time the
36
- * user reaches liveness this is normally already `'ready'` and no waiting screen
37
- * is ever shown.
13
+ * `'preparing'` while Play Services fetches the model, `'ready'` once it can
14
+ * run, `'unavailable'` when it could not be obtained — no Play Services, no
15
+ * network, or a declined install.
38
16
  */
39
17
  export function useFaceModelReady(): ModelReadyState {
40
- const [state, setState] = useState<ModelReadyState>(() =>
41
- isFaceModelReady() ? 'ready' : 'preparing',
42
- );
43
- const startedAt = useRef(Date.now());
44
-
45
- useEffect(() => {
46
- if (state !== 'preparing') return;
47
-
48
- // Re-prime rather than assume the open-time call ran: the step can be
49
- // reached directly in a resumed flow, and prepareModel() is a no-op once
50
- // the model is present.
51
- primeFaceModel();
52
-
53
- const id = setInterval(() => {
54
- if (isFaceModelReady()) {
55
- setState('ready');
56
- } else if (Date.now() - startedAt.current > MODEL_WAIT_MS) {
57
- setState('unavailable');
58
- }
59
- }, POLL_INTERVAL_MS);
60
-
61
- return () => clearInterval(id);
62
- }, [state]);
63
-
64
- return state;
18
+ return useNativeModelReady(isFaceModelReady, primeFaceModel);
65
19
  }
@@ -57,6 +57,15 @@ function fit(line: string, width: number): string | null {
57
57
  if (line.length >= width - 2 && line.length < width) return line.padEnd(width, '<');
58
58
  // Long by a stray glyph or three: drop the trailing noise.
59
59
  if (line.length > width && line.length <= width + 3) return line.slice(0, width);
60
+ // A filler run the recogniser miscounted. Android returns runs of '<' as
61
+ // guillemets whose number does not match the fillers printed, so the mapped
62
+ // run overshoots or falls short by many characters (a passport's first line
63
+ // came back 53 wide on the Flutter SDK, 2026-09-15). Trailing fillers are
64
+ // padding and carry no data, so only they are removed or added.
65
+ if (line.length > width && !/[^<]/.test(line.slice(width))) return line.slice(0, width);
66
+ if (line.length < width && line.length >= Math.floor(width / 2) && line.endsWith('<')) {
67
+ return line.padEnd(width, '<');
68
+ }
60
69
  return null;
61
70
  }
62
71
 
@@ -65,10 +74,9 @@ function fit(line: string, width: number): string | null {
65
74
  * not carry a complete, valid one.
66
75
  */
67
76
  export function extractMrz(recognizedLines: string[], now: Date = new Date()): MrzScan | null {
68
- const candidates = recognizedLines
69
- .map(sanitizeMrzLine)
70
- .filter(looksLikeMrzLine);
71
- if (candidates.length === 0) return null;
77
+ const pieces = recognizedLines.map(sanitizeMrzLine).filter((line) => line.length > 0);
78
+ const candidates = pieces.filter(looksLikeMrzLine);
79
+ if (candidates.length === 0) return fromFragments(pieces, now);
72
80
 
73
81
  // 1) A single line already holding the whole MRZ (some recognisers merge).
74
82
  for (const line of candidates) {
@@ -97,5 +105,64 @@ export function extractMrz(recognizedLines: string[], now: Date = new Date()): M
97
105
  if (scan) return scan;
98
106
  }
99
107
 
108
+ return fromFragments(pieces, now);
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Split lines.
113
+ //
114
+ // On a high-resolution still, Android's recogniser can return ONE printed MRZ
115
+ // line as two text lines (seen on the Flutter SDK, 2026-09-15: a passport's
116
+ // two-line band came back as three lines). Joining runs of adjacent pieces back
117
+ // to line width recovers it; a wrong join is harmless, as above. Mirrored in
118
+ // kyc-sdk-flutter's mrz_extract.dart.
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /** The longest run of pieces one printed line is ever split into. */
122
+ const MAX_PIECES_PER_LINE = 4;
123
+
124
+ interface JoinedRow {
125
+ end: number;
126
+ text: string;
127
+ }
128
+
129
+ /** Joined rows of `width`, keyed by the index of the piece each one starts at. */
130
+ function rowsByStart(pieces: string[], width: number): Map<number, JoinedRow[]> {
131
+ const rows = new Map<number, JoinedRow[]>();
132
+ for (let i = 0; i < pieces.length; i++) {
133
+ let text = '';
134
+ for (let j = i; j < pieces.length && j < i + MAX_PIECES_PER_LINE; j++) {
135
+ text += pieces[j]!;
136
+ // Generous: a joined row may carry an over-counted filler run `fit` trims.
137
+ if (text.length > width * 2) break;
138
+ const fitted = fit(text, width);
139
+ if (fitted) rows.set(i, [...(rows.get(i) ?? []), { end: j, text: fitted }]);
140
+ }
141
+ }
142
+ return rows;
143
+ }
144
+
145
+ /** Consecutive joined rows of line width: TD3 two of 44, TD1 three of 30. */
146
+ function fromFragments(pieces: string[], now: Date): MrzScan | null {
147
+ const td3 = rowsByStart(pieces, 44);
148
+ for (const list of td3.values()) {
149
+ for (const a of list) {
150
+ for (const b of td3.get(a.end + 1) ?? []) {
151
+ const scan = parseMrz(a.text + b.text, now);
152
+ if (scan) return scan;
153
+ }
154
+ }
155
+ }
156
+ const td1 = rowsByStart(pieces, 30);
157
+ for (const list of td1.values()) {
158
+ for (const a of list) {
159
+ for (const b of td1.get(a.end + 1) ?? []) {
160
+ for (const c of td1.get(b.end + 1) ?? []) {
161
+ const scan = parseMrz(a.text + b.text + c.text, now);
162
+ if (scan) return scan;
163
+ }
164
+ }
165
+ }
166
+ }
100
167
  return null;
101
168
  }
@@ -71,6 +71,42 @@ export function hasTextRecognizer(): boolean {
71
71
  return resolve() !== null;
72
72
  }
73
73
 
74
+ /**
75
+ * Start fetching the text model, so it is warm by the time the document step
76
+ * runs.
77
+ *
78
+ * Android fetches ML Kit's models through Play Services rather than bundling
79
+ * them, which keeps ~18.5 MB per device out of the APK but leaves a window on
80
+ * first launch where recognition cannot work. Priming at flow start puts that
81
+ * download behind the consent and ID-type screens instead of in front of the
82
+ * camera.
83
+ *
84
+ * Best-effort and non-blocking, exactly like `primeFaceModel()`: a failure here
85
+ * is not fatal, because {@link isTextModelReady} is what the document step
86
+ * actually reads. iOS resolves immediately (Apple Vision is a system
87
+ * framework).
88
+ */
89
+ export function primeTextModel(): void {
90
+ resolve()?.prepareModel().catch(() => {
91
+ /* best-effort: isTextModelReady() is the real signal */
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Whether text recognition can run right now.
97
+ *
98
+ * `recognizeText` cannot answer this — a missing model and a frame with no text
99
+ * are both an empty `lines` array — so auto-capture would silently never fire
100
+ * and the MRZ would never produce a chip key, with nothing able to say why.
101
+ *
102
+ * `false` when the native module is absent entirely (Expo Go), because in that
103
+ * build there is no recogniser to become ready. Callers treat both the same
104
+ * way: fall back to the manual shutter, which was always the primary route.
105
+ */
106
+ export function isTextModelReady(): boolean {
107
+ return resolve()?.isModelReady() ?? false;
108
+ }
109
+
74
110
  /**
75
111
  * The bottom slice of the frame the MRZ occupies.
76
112
  *
@@ -0,0 +1,21 @@
1
+ import { useNativeModelReady, type ModelReadyState } from '../lib/model-ready';
2
+ import { isTextModelReady, primeTextModel } from './textRecognizer';
3
+
4
+ // The text half of the shared readiness gate. The rule, the polling and the
5
+ // reasoning live in ../lib/model-ready.ts — the same gate the face detector
6
+ // uses, for the same reason.
7
+
8
+ export type { ModelReadyState };
9
+
10
+ /**
11
+ * Tracks whether on-device text recognition can run.
12
+ *
13
+ * The live MRZ scanner is what gates on this: the printed strip IS the chip's
14
+ * access key, so a recogniser that can never run leaves the user aiming at a
15
+ * passport that is silently never read. Auto-capture deliberately does NOT gate
16
+ * on it — there the recogniser is an accelerator and the manual shutter is
17
+ * always live, so an absent model costs convenience rather than a dead end.
18
+ */
19
+ export function useTextModelReady(): ModelReadyState {
20
+ return useNativeModelReady(isTextModelReady, primeTextModel);
21
+ }
@@ -64,6 +64,7 @@ function UploadedThumb({ previewUri, isPdf }: { previewUri: string | null; isPdf
64
64
 
65
65
  export function BusinessDocumentSlot({
66
66
  label,
67
+ description = null,
67
68
  required,
68
69
  fileName,
69
70
  uploading,
@@ -71,8 +72,16 @@ export function BusinessDocumentSlot({
71
72
  isPdf = false,
72
73
  onPick,
73
74
  onRemove,
75
+ compact = false,
74
76
  }: {
75
77
  label: string;
78
+ /**
79
+ * Guidance the organisation wrote: which document, and what it has to show.
80
+ * Optional because the business-documents step names a fixed catalogue the
81
+ * applicant already recognises; a supporting document is whatever the org
82
+ * called it, so this is often the only thing that makes it findable.
83
+ */
84
+ description?: string | null;
76
85
  required: boolean;
77
86
  /** Uploaded file name, when this slot already has a mediaId. */
78
87
  fileName: string | null;
@@ -83,6 +92,15 @@ export function BusinessDocumentSlot({
83
92
  /** Open the photo/file source sheet (also serves Replace). */
84
93
  onPick: () => void;
85
94
  onRemove: () => void;
95
+ /**
96
+ * The slot is the FOOTER of a card that already names the document.
97
+ *
98
+ * So it drops everything the card above it has already said — the label
99
+ * echo, the required star, the guidance — and its own bottom margin, and
100
+ * the empty state reads "Upload <document>" rather than repeating the title.
101
+ * Mirrors the Flutter slot's `compact`.
102
+ */
103
+ compact?: boolean;
86
104
  }): React.ReactElement {
87
105
  const { colors } = useTheme();
88
106
 
@@ -97,7 +115,7 @@ export function BusinessDocumentSlot({
97
115
  borderRadius: radius.sm,
98
116
  backgroundColor: colors.backgroundSecondary,
99
117
  padding: spacing.md,
100
- marginBottom: spacing.sm + 4,
118
+ marginBottom: compact ? 0 : spacing.sm + 4,
101
119
  }}
102
120
  >
103
121
  <UploadedThumb previewUri={previewUri} isPdf={isPdf} />
@@ -106,9 +124,11 @@ export function BusinessDocumentSlot({
106
124
  <MyazaText variant="label" style={{ fontWeight: '600' }} numberOfLines={1}>
107
125
  {fileName}
108
126
  </MyazaText>
109
- <MyazaText variant="bodySmall" color={colors.textMuted}>
110
- {label}
111
- </MyazaText>
127
+ {compact ? null : (
128
+ <MyazaText variant="bodySmall" color={colors.textMuted}>
129
+ {label}
130
+ </MyazaText>
131
+ )}
112
132
  </View>
113
133
  <Pressable onPress={onPick} hitSlop={8} accessibilityRole="button">
114
134
  <MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '600' }}>
@@ -138,7 +158,7 @@ export function BusinessDocumentSlot({
138
158
  alignItems: 'center',
139
159
  borderRadius: radius.sm,
140
160
  padding: spacing.md,
141
- marginBottom: spacing.sm + 4,
161
+ marginBottom: compact ? 0 : spacing.sm + 4,
142
162
  opacity: uploading ? 0.7 : 1,
143
163
  }}
144
164
  >
@@ -150,14 +170,23 @@ export function BusinessDocumentSlot({
150
170
  )}
151
171
  <View style={{ width: spacing.sm + 4 }} />
152
172
  <View style={{ flex: 1, minWidth: 0 }}>
153
- <MyazaText variant="label" style={{ fontWeight: '600' }} numberOfLines={1}>
154
- {label}
155
- {required ? (
173
+ <MyazaText variant="label" style={{ fontWeight: '600' }} numberOfLines={2}>
174
+ {compact ? `Upload ${label.toLowerCase()}` : label}
175
+ {required && !compact ? (
156
176
  <MyazaText variant="label" color={colors.error} style={{ fontWeight: '600' }}>
157
177
  {' *'}
158
178
  </MyazaText>
159
179
  ) : null}
160
180
  </MyazaText>
181
+ {description && !uploading && !compact ? (
182
+ <MyazaText
183
+ variant="bodySmall"
184
+ color={colors.textDark}
185
+ style={{ marginTop: 2, opacity: 0.75 }}
186
+ >
187
+ {description}
188
+ </MyazaText>
189
+ ) : null}
161
190
  <MyazaText variant="bodySmall" color={colors.textMuted}>
162
191
  {uploading ? 'Uploading…' : UPLOAD_HINT}
163
192
  </MyazaText>