mirror of
https://github.com/selfxyz/self.git
synced 2026-01-09 14:48:06 -05:00
Fix mobile demo app document registration (#1182)
* Enable WebSocket connections in demo client * save working keychain * save wip * save polish tweaks * downgrade react-native-svg * abstract components * onSuccess alert displays only once * sort by registered first * add clear all documents button * formatting and typing * refresh register document screen after successful registration * fix double tap on register * coderabbit feedback * lock NFCPassportReader to commit * remove react native picker * remove lock * minor fixes
This commit is contained in:
@@ -43,6 +43,7 @@ export interface DocumentMetadata {
|
||||
data: string; // DG1/MRZ data for passports/IDs, relevant data for aadhaar
|
||||
mock: boolean; // whether this is a mock document
|
||||
isRegistered?: boolean; // whether the document is registered onChain
|
||||
registeredAt?: number; // timestamp (epoch ms) when document was registered
|
||||
}
|
||||
|
||||
export type DocumentType =
|
||||
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
brutforceSignatureAlgorithmDsc,
|
||||
calculateContentHash,
|
||||
inferDocumentCategory,
|
||||
isAadhaarDocument,
|
||||
isMRZDocument,
|
||||
parseCertificateSimple,
|
||||
} from '@selfxyz/common';
|
||||
|
||||
import { extractNameFromMRZ } from '../processing/mrz';
|
||||
import { SelfClient } from '../types/public';
|
||||
|
||||
export async function clearPassportData(selfClient: SelfClient) {
|
||||
@@ -35,6 +37,53 @@ export async function clearPassportData(selfClient: SelfClient) {
|
||||
await selfClient.saveDocumentCatalog({ documents: [] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract name from a document by loading its full data.
|
||||
* Works for both MRZ documents (passport/ID card) and Aadhaar documents.
|
||||
*
|
||||
* @param selfClient - The SelfClient instance
|
||||
* @param documentId - The document ID to extract name from
|
||||
* @returns Object with firstName and lastName, or null if extraction fails
|
||||
*/
|
||||
export async function extractNameFromDocument(
|
||||
selfClient: SelfClient,
|
||||
documentId: string,
|
||||
): Promise<{ firstName: string; lastName: string } | null> {
|
||||
try {
|
||||
const document = await selfClient.loadDocumentById(documentId);
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For Aadhaar documents, extract name from extractedFields
|
||||
if (isAadhaarDocument(document)) {
|
||||
const name = document.extractedFields?.name;
|
||||
if (name && typeof name === 'string') {
|
||||
// Aadhaar name is typically "FIRSTNAME LASTNAME" format
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
const firstName = parts[0];
|
||||
const lastName = parts.slice(1).join(' ');
|
||||
return { firstName, lastName };
|
||||
} else if (parts.length === 1) {
|
||||
return { firstName: parts[0], lastName: '' };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// For MRZ documents (passport/ID card), extract from MRZ string
|
||||
if (isMRZDocument(document)) {
|
||||
return extractNameFromMRZ(document.mrz);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error extracting name from document:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all documents from the document catalog.
|
||||
*
|
||||
@@ -219,6 +268,14 @@ export async function updateDocumentRegistrationState(
|
||||
if (documentIndex !== -1) {
|
||||
catalog.documents[documentIndex].isRegistered = isRegistered;
|
||||
|
||||
// Set registration timestamp when marking as registered
|
||||
if (isRegistered) {
|
||||
catalog.documents[documentIndex].registeredAt = Date.now();
|
||||
} else {
|
||||
// Clear timestamp when unregistering
|
||||
catalog.documents[documentIndex].registeredAt = undefined;
|
||||
}
|
||||
|
||||
await selfClient.saveDocumentCatalog(catalog);
|
||||
|
||||
console.log(`Updated registration state for document ${documentId}: ${isRegistered}`);
|
||||
|
||||
@@ -90,6 +90,9 @@ export { defaultConfig } from './config/defaults';
|
||||
|
||||
export { extractMRZInfo, extractNameFromMRZ, formatDateToYYMMDD, scanMRZ } from './mrz';
|
||||
|
||||
// Document utils
|
||||
export { extractNameFromDocument } from './documents/utils';
|
||||
|
||||
export { generateMockDocument, signatureAlgorithmToStrictSignatureAlgorithm } from './mock/generator';
|
||||
|
||||
// Core functions
|
||||
|
||||
@@ -276,10 +276,16 @@ export function extractNameFromMRZ(mrzString: string): { firstName: string; last
|
||||
.filter(Boolean);
|
||||
|
||||
// Handle single-line MRZ strings (common for stored data)
|
||||
// TD3 format: 88 or 90 characters total (2 lines of 44 or 45 chars each)
|
||||
if (lines.length === 1) {
|
||||
const mrzLength = lines[0].length;
|
||||
if (mrzLength === 88 || mrzLength === 90) {
|
||||
|
||||
// TD1 format (ID card): 90 characters = 3 lines × 30 chars
|
||||
// Detect TD1 by checking if it starts with 'I' (ID card) or 'A' (type A) or 'C' (type C)
|
||||
if (mrzLength === 90 && /^[IAC][<A-Z]/.test(lines[0])) {
|
||||
lines = [lines[0].slice(0, 30), lines[0].slice(30, 60), lines[0].slice(60, 90)];
|
||||
}
|
||||
// TD3 format (passport): 88 chars (2×44) or 90 chars (2×45)
|
||||
else if (mrzLength === 88 || mrzLength === 90) {
|
||||
const lineLength = mrzLength === 88 ? 44 : 45;
|
||||
lines = [lines[0].slice(0, lineLength), lines[0].slice(lineLength)];
|
||||
}
|
||||
@@ -294,7 +300,7 @@ export function extractNameFromMRZ(mrzString: string): { firstName: string; last
|
||||
// TD3 typically has 2 lines, first line is usually 44 chars but we'll be lenient
|
||||
if (lines.length === 2) {
|
||||
const line1 = lines[0];
|
||||
const nameMatch = line1.match(/^P<[A-Z]{3}(.+)$/);
|
||||
const nameMatch = line1.match(/^[IPO]<[A-Z]{3}(.+)$/);
|
||||
|
||||
if (nameMatch) {
|
||||
const namePart = nameMatch[1];
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface DocumentMetadata {
|
||||
encryptedBlobRef?: string; // opaque pointer; no plaintext PII
|
||||
mock: boolean;
|
||||
isRegistered?: boolean;
|
||||
registeredAt?: number; // timestamp (epoch ms) when document was registered
|
||||
}
|
||||
|
||||
export interface DocumentData {
|
||||
|
||||
@@ -26,7 +26,7 @@ target "SelfDemoApp" do
|
||||
)
|
||||
|
||||
# Use the custom NFCPassportReader fork
|
||||
pod "NFCPassportReader", :git => "git@github.com:selfxyz/NFCPassportReader.git"
|
||||
pod "NFCPassportReader", :git => "git@github.com:selfxyz/NFCPassportReader.git", :commit => "9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b"
|
||||
|
||||
post_install do |installer|
|
||||
# https://github.com/facebook/react-native/blob/main/packages/react-native/scripts/react_native_pods.rb#L197-L202
|
||||
|
||||
@@ -1659,7 +1659,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- RNCPicker (2.11.2):
|
||||
- RNKeychain (10.0.0):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
@@ -1680,7 +1680,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- RNSVG (15.13.0):
|
||||
- RNSVG (15.12.1):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
@@ -1700,9 +1700,9 @@ PODS:
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- RNSVG/common (= 15.13.0)
|
||||
- RNSVG/common (= 15.12.1)
|
||||
- Yoga
|
||||
- RNSVG/common (15.13.0):
|
||||
- RNSVG/common (15.12.1):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
@@ -1756,7 +1756,7 @@ DEPENDENCIES:
|
||||
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
|
||||
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
|
||||
- "mobile-sdk-alpha (from `../node_modules/@selfxyz/mobile-sdk-alpha`)"
|
||||
- "NFCPassportReader (from `git@github.com:selfxyz/NFCPassportReader.git`)"
|
||||
- "NFCPassportReader (from `git@github.com:selfxyz/NFCPassportReader.git`, commit `9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b`)"
|
||||
- RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
|
||||
- RCT-Folly/Fabric (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
|
||||
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
|
||||
@@ -1818,7 +1818,7 @@ DEPENDENCIES:
|
||||
- ReactCodegen (from `build/generated/ios`)
|
||||
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
|
||||
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
|
||||
- "RNCPicker (from `../node_modules/@react-native-picker/picker`)"
|
||||
- RNKeychain (from `../node_modules/react-native-keychain`)
|
||||
- RNSVG (from `../node_modules/react-native-svg`)
|
||||
- RNVectorIcons (from `../node_modules/react-native-vector-icons`)
|
||||
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
|
||||
@@ -1849,6 +1849,7 @@ EXTERNAL SOURCES:
|
||||
mobile-sdk-alpha:
|
||||
:path: "../node_modules/@selfxyz/mobile-sdk-alpha"
|
||||
NFCPassportReader:
|
||||
:commit: 9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b
|
||||
:git: "git@github.com:selfxyz/NFCPassportReader.git"
|
||||
RCT-Folly:
|
||||
:podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec"
|
||||
@@ -1968,8 +1969,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/react-native/ReactCommon"
|
||||
RNCAsyncStorage:
|
||||
:path: "../node_modules/@react-native-async-storage/async-storage"
|
||||
RNCPicker:
|
||||
:path: "../node_modules/@react-native-picker/picker"
|
||||
RNKeychain:
|
||||
:path: "../node_modules/react-native-keychain"
|
||||
RNSVG:
|
||||
:path: "../node_modules/react-native-svg"
|
||||
RNVectorIcons:
|
||||
@@ -1979,7 +1980,7 @@ EXTERNAL SOURCES:
|
||||
|
||||
CHECKOUT OPTIONS:
|
||||
NFCPassportReader:
|
||||
:commit: 04ede227cbfd377e2b4bc9b38f9a89eebdcab52f
|
||||
:commit: 9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b
|
||||
:git: "git@github.com:selfxyz/NFCPassportReader.git"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
@@ -2054,12 +2055,12 @@ SPEC CHECKSUMS:
|
||||
ReactCodegen: f853a20cc9125c5521c8766b4b49375fec20648b
|
||||
ReactCommon: 300d8d9c5cb1a6cd79a67cf5d8f91e4d477195f9
|
||||
RNCAsyncStorage: 87a74d13ba0128f853817e45e21c4051e1f2cd45
|
||||
RNCPicker: 31b0c81be6b949dbd8d0c8802e9c6b9615de880a
|
||||
RNSVG: c22ddda11213ee91192ab2f70b50c78a8bbc30d8
|
||||
RNKeychain: 850638785745df5f70c37251130617a66ec82102
|
||||
RNSVG: 8dd938fb169dd81009b74c2334780d7d2a04a373
|
||||
RNVectorIcons: c95fdae217b0ed388f2b4d7ed7a4edc457c1df47
|
||||
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
|
||||
Yoga: feb4910aba9742cfedc059e2b2902e22ffe9954a
|
||||
|
||||
PODFILE CHECKSUM: 22f8edb659097ec6a47366d55dcd021f5b88ccdb
|
||||
PODFILE CHECKSUM: 7bafdc4607a2a09088e9b68be33648f72b535141
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"@faker-js/faker": "^10.0.0",
|
||||
"@noble/hashes": "^1.5.0",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-native-picker/picker": "^2.11.1",
|
||||
"@react-native/gradle-plugin": "0.76.9",
|
||||
"@selfxyz/common": "workspace:*",
|
||||
"@selfxyz/mobile-sdk-alpha": "workspace:*",
|
||||
@@ -44,7 +43,7 @@
|
||||
"react-native-get-random-values": "^1.11.0",
|
||||
"react-native-keychain": "^10.0.0",
|
||||
"react-native-safe-area-context": "^5.6.1",
|
||||
"react-native-svg": "^15.13.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-vector-icons": "^10.3.0",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"util": "^0.12.5"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React from 'react';
|
||||
import { countryCodes } from '@selfxyz/common';
|
||||
import { signatureAlgorithmToStrictSignatureAlgorithm } from '@selfxyz/mobile-sdk-alpha';
|
||||
import { PickerField } from './PickerField';
|
||||
|
||||
const algorithmOptions = Object.keys(signatureAlgorithmToStrictSignatureAlgorithm);
|
||||
const countryOptions = Object.keys(countryCodes);
|
||||
|
||||
export function AlgorithmCountryFields({
|
||||
show,
|
||||
algorithm,
|
||||
setAlgorithm,
|
||||
country,
|
||||
setCountry,
|
||||
}: {
|
||||
show: boolean;
|
||||
algorithm: string;
|
||||
setAlgorithm: (value: string) => void;
|
||||
country: string;
|
||||
setCountry: (value: string) => void;
|
||||
}) {
|
||||
if (!show) return null;
|
||||
return (
|
||||
<>
|
||||
<PickerField
|
||||
label="Algorithm"
|
||||
selectedValue={algorithm}
|
||||
onValueChange={setAlgorithm}
|
||||
items={algorithmOptions.map(alg => ({ label: alg, value: alg }))}
|
||||
/>
|
||||
<PickerField
|
||||
label="Country"
|
||||
selectedValue={country}
|
||||
onValueChange={setCountry}
|
||||
items={countryOptions.map(code => ({
|
||||
label: `${code} - ${countryCodes[code as keyof typeof countryCodes]}`,
|
||||
value: code,
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
43
packages/mobile-sdk-demo/src/components/PickerField.tsx
Normal file
43
packages/mobile-sdk-demo/src/components/PickerField.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { SimplePicker } from './SimplePicker';
|
||||
import type { PickerItem } from './SimplePicker';
|
||||
|
||||
export { type PickerItem };
|
||||
|
||||
export function PickerField({
|
||||
label,
|
||||
selectedValue,
|
||||
onValueChange,
|
||||
items,
|
||||
enabled = true,
|
||||
}: {
|
||||
label: string;
|
||||
selectedValue: string;
|
||||
onValueChange: (value: string) => void;
|
||||
items: PickerItem[];
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<SimplePicker enabled={enabled} selectedValue={selectedValue} onValueChange={onValueChange} items={items} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
inputContainer: {
|
||||
marginBottom: 10,
|
||||
},
|
||||
label: {
|
||||
marginBottom: 4,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
@@ -13,12 +13,13 @@ type Props = {
|
||||
onBack: () => void;
|
||||
children: React.ReactNode;
|
||||
contentStyle?: ViewStyle;
|
||||
rightAction?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function ScreenLayout({ title, onBack, children, contentStyle }: Props) {
|
||||
export default function ScreenLayout({ title, onBack, children, contentStyle, rightAction }: Props) {
|
||||
return (
|
||||
<SafeAreaScrollView contentContainerStyle={styles.container} backgroundColor="#fafbfc">
|
||||
<StandardHeader title={title} onBack={onBack} />
|
||||
<StandardHeader title={title} onBack={onBack} rightAction={rightAction} />
|
||||
<View style={[styles.content, contentStyle]}>{children}</View>
|
||||
</SafeAreaScrollView>
|
||||
);
|
||||
|
||||
117
packages/mobile-sdk-demo/src/components/SimplePicker.tsx
Normal file
117
packages/mobile-sdk-demo/src/components/SimplePicker.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { FlatList, Modal, Pressable, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Icon from 'react-native-vector-icons/Ionicons';
|
||||
|
||||
export type PickerItem = { label: string; value: string };
|
||||
|
||||
type SimplePickerProps = {
|
||||
items: PickerItem[];
|
||||
selectedValue: string;
|
||||
onValueChange: (value: string) => void;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export function SimplePicker({ items, selectedValue, onValueChange, enabled = true }: SimplePickerProps) {
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const selectedItem = items.find(item => item.value === selectedValue);
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
onValueChange(value);
|
||||
setModalVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
onPress={() => enabled && setModalVisible(true)}
|
||||
style={[styles.pickerPressable, !enabled && styles.disabled]}
|
||||
>
|
||||
<Text style={styles.pickerText}>{selectedItem?.label || 'Select...'}</Text>
|
||||
<Icon name="chevron-down-outline" size={20} color="#000" />
|
||||
</Pressable>
|
||||
|
||||
<Modal
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
visible={modalVisible}
|
||||
onRequestClose={() => setModalVisible(false)}
|
||||
>
|
||||
<SafeAreaView style={styles.modalContainer}>
|
||||
<View style={styles.modalContent}>
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={item => item.value}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity style={styles.optionItem} onPress={() => handleSelect(item.value)}>
|
||||
<Text style={styles.optionText}>{item.label}</Text>
|
||||
{item.value === selectedValue && <Icon name="checkmark-outline" size={24} color="#007AFF" />}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
<TouchableOpacity style={styles.closeButton} onPress={() => setModalVisible(false)}>
|
||||
<Text style={styles.closeButtonText}>Close</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pickerPressable: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderWidth: 1,
|
||||
borderColor: '#ccc',
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#fff',
|
||||
paddingHorizontal: 12,
|
||||
height: 44,
|
||||
},
|
||||
pickerText: {
|
||||
color: '#000',
|
||||
fontSize: 14,
|
||||
},
|
||||
disabled: {
|
||||
backgroundColor: '#f0f0f0',
|
||||
},
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: '#fff',
|
||||
borderTopLeftRadius: 10,
|
||||
borderTopRightRadius: 10,
|
||||
maxHeight: '50%',
|
||||
},
|
||||
optionItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 15,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
optionText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
closeButton: {
|
||||
padding: 15,
|
||||
alignItems: 'center',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#eee',
|
||||
},
|
||||
closeButtonText: {
|
||||
fontSize: 16,
|
||||
color: '#007AFF',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -9,15 +9,19 @@ import Icon from 'react-native-vector-icons/Ionicons';
|
||||
type Props = {
|
||||
title: string;
|
||||
onBack: () => void;
|
||||
rightAction?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function StandardHeader({ title, onBack }: Props) {
|
||||
export default function StandardHeader({ title, onBack, rightAction }: Props) {
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity style={styles.backButton} onPress={onBack}>
|
||||
<Icon name="chevron-back" size={20} color="#0550ae" />
|
||||
<Text style={styles.backButtonText}>Back</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.topRow}>
|
||||
<TouchableOpacity style={styles.backButton} onPress={onBack}>
|
||||
<Icon name="chevron-back" size={20} color="#0550ae" />
|
||||
<Text style={styles.backButtonText}>Back</Text>
|
||||
</TouchableOpacity>
|
||||
{rightAction}
|
||||
</View>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -27,13 +31,18 @@ const styles = StyleSheet.create({
|
||||
header: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
backButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignSelf: 'flex-start',
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
marginBottom: 8,
|
||||
marginLeft: -12,
|
||||
},
|
||||
backButtonText: {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type { DocumentMetadata, IDDocument } from '@selfxyz/common/dist/esm/src/utils/types.js';
|
||||
import type { DocumentCatalog, DocumentMetadata, IDDocument } from '@selfxyz/common/dist/esm/src/utils/types.js';
|
||||
import { getAllDocuments, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import { updateAfterDelete } from '../lib/catalog';
|
||||
@@ -20,13 +20,24 @@ export function useDocuments() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const all = await getAllDocuments(selfClient);
|
||||
setDocuments(Object.values(all));
|
||||
const sortedDocuments = Object.values(all).sort((a, b) => {
|
||||
// Registered documents first
|
||||
if (a.metadata.isRegistered && !b.metadata.isRegistered) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.metadata.isRegistered && b.metadata.isRegistered) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
setDocuments(sortedDocuments);
|
||||
} catch (err) {
|
||||
setDocuments([]);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
@@ -55,5 +66,42 @@ export function useDocuments() {
|
||||
[selfClient, refresh],
|
||||
);
|
||||
|
||||
return { documents, loading, error, deleting, refresh, deleteDocument } as const;
|
||||
const clearAllDocuments = useCallback(async () => {
|
||||
setClearing(true);
|
||||
setError(null);
|
||||
let originalCatalog: DocumentCatalog | null = null;
|
||||
try {
|
||||
// Read and persist the existing catalog.
|
||||
originalCatalog = await selfClient.loadDocumentCatalog();
|
||||
const docIds = originalCatalog.documents.map(d => d.id);
|
||||
|
||||
// Write an empty catalog to atomically remove references.
|
||||
const emptyCatalog = {
|
||||
documents: [],
|
||||
selectedDocumentId: undefined,
|
||||
};
|
||||
await selfClient.saveDocumentCatalog(emptyCatalog);
|
||||
|
||||
try {
|
||||
// Then perform deletions of document ids from storage.
|
||||
for (const docId of docIds) {
|
||||
await selfClient.deleteDocument(docId);
|
||||
}
|
||||
} catch (deletionError) {
|
||||
// If any deletion fails, restore the previous catalog and re-throw.
|
||||
if (originalCatalog) {
|
||||
await selfClient.saveDocumentCatalog(originalCatalog);
|
||||
}
|
||||
throw deletionError; // Re-throw to be caught by the outer catch block.
|
||||
}
|
||||
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
}, [selfClient, refresh]);
|
||||
|
||||
return { documents, loading, error, deleting, clearing, refresh, deleteDocument, clearAllDocuments } as const;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export function useRegistration() {
|
||||
const init = useProvingStore(state => state.init);
|
||||
const setUserConfirmed = useProvingStore(state => state.setUserConfirmed);
|
||||
const autoConfirmTimer = useRef<NodeJS.Timeout>();
|
||||
const onCompleteRef = useRef<null | (() => void)>(null);
|
||||
|
||||
const [registering, setRegistering] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
@@ -45,6 +46,24 @@ export function useRegistration() {
|
||||
return () => unsubscribe();
|
||||
}, [selfClient, registering, addLog]);
|
||||
|
||||
// Also listen for explicit SDK success event as a reliable completion signal
|
||||
useEffect(() => {
|
||||
if (!registering) return;
|
||||
const unsubscribe = selfClient.on(SdkEvents.PROVING_ACCOUNT_VERIFIED_SUCCESS, () => {
|
||||
setStatusMessage('🎉 Registration completed successfully!');
|
||||
addLog('Document registered on-chain! (event)', 'info');
|
||||
if (onCompleteRef.current) {
|
||||
try {
|
||||
onCompleteRef.current();
|
||||
} finally {
|
||||
onCompleteRef.current = null;
|
||||
}
|
||||
}
|
||||
setRegistering(false);
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, [selfClient, registering, addLog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!registering) return;
|
||||
switch (currentState) {
|
||||
@@ -88,6 +107,13 @@ export function useRegistration() {
|
||||
setStatusMessage('🎉 Registration completed successfully!');
|
||||
addLog('Document registered on-chain!', 'info');
|
||||
setRegistering(false);
|
||||
if (onCompleteRef.current) {
|
||||
try {
|
||||
onCompleteRef.current();
|
||||
} finally {
|
||||
onCompleteRef.current = null; // ensure one-shot
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
case 'failure':
|
||||
@@ -122,12 +148,18 @@ export function useRegistration() {
|
||||
state: { registering, statusMessage, currentState, logs, showLogs },
|
||||
actions: {
|
||||
start,
|
||||
setOnComplete: (cb: (() => void) | null) => {
|
||||
onCompleteRef.current = cb;
|
||||
},
|
||||
toggleLogs: () => setShowLogs(s => !s),
|
||||
reset: () => {
|
||||
setRegistering(false);
|
||||
setStatusMessage('');
|
||||
setLogs([]);
|
||||
setShowLogs(false);
|
||||
onCompleteRef.current = null;
|
||||
// Reset the SDK's proving store state to prevent stale 'completed' state
|
||||
useProvingStore.setState({ currentState: 'idle' });
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -29,19 +29,84 @@ const createFetch = () => {
|
||||
return (input: RequestInfo | URL, init?: RequestInit) => fetchImpl(input, init);
|
||||
};
|
||||
|
||||
const createWsAdapter = () => ({
|
||||
connect: (_url: string): WsConn => {
|
||||
const createWsAdapter = () => {
|
||||
const WebSocketImpl = globalThis.WebSocket;
|
||||
|
||||
if (!WebSocketImpl) {
|
||||
return {
|
||||
send: () => {
|
||||
throw new Error('WebSocket send is not implemented in the demo environment.');
|
||||
connect: () => {
|
||||
throw new Error('WebSocket is not available in this environment. Provide a WebSocket implementation.');
|
||||
},
|
||||
close: () => {},
|
||||
onMessage: () => {},
|
||||
onError: () => {},
|
||||
onClose: () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
connect: (url: string, opts?: { signal?: AbortSignal; headers?: Record<string, string> }): WsConn => {
|
||||
const socket = new WebSocketImpl(url);
|
||||
|
||||
let abortHandler: (() => void) | null = null;
|
||||
|
||||
if (opts?.signal) {
|
||||
abortHandler = () => {
|
||||
socket.close();
|
||||
};
|
||||
|
||||
if (typeof opts.signal.addEventListener === 'function') {
|
||||
opts.signal.addEventListener('abort', abortHandler, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
const attach = (event: 'message' | 'error' | 'close', handler: (payload?: any) => void) => {
|
||||
// Clean up abort listener when socket closes
|
||||
if (event === 'close' && abortHandler && opts?.signal) {
|
||||
const originalHandler = handler;
|
||||
handler = (payload?: any) => {
|
||||
if (typeof opts.signal!.removeEventListener === 'function') {
|
||||
opts.signal!.removeEventListener('abort', abortHandler!);
|
||||
}
|
||||
originalHandler(payload);
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof socket.addEventListener === 'function') {
|
||||
if (event === 'message') {
|
||||
(socket.addEventListener as any)('message', handler as any);
|
||||
} else if (event === 'error') {
|
||||
(socket.addEventListener as any)('error', handler as any);
|
||||
} else {
|
||||
(socket.addEventListener as any)('close', handler as any);
|
||||
}
|
||||
} else {
|
||||
if (event === 'message') {
|
||||
(socket as any).onmessage = handler;
|
||||
} else if (event === 'error') {
|
||||
(socket as any).onerror = handler;
|
||||
} else {
|
||||
(socket as any).onclose = handler;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
send: (data: string | ArrayBufferView | ArrayBuffer) => socket.send(data),
|
||||
close: () => socket.close(),
|
||||
onMessage: cb => {
|
||||
attach('message', event => {
|
||||
// React Native emits { data }, whereas browsers emit MessageEvent.
|
||||
const payload = (event as { data?: unknown }).data ?? event;
|
||||
cb(payload);
|
||||
});
|
||||
},
|
||||
onError: cb => {
|
||||
attach('error', error => cb(error));
|
||||
},
|
||||
onClose: cb => {
|
||||
attach('close', () => cb());
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const hash = (data: Uint8Array): Uint8Array => sha256(data);
|
||||
|
||||
@@ -74,7 +139,9 @@ export function SelfClientProvider({ children }: PropsWithChildren) {
|
||||
auth: {
|
||||
async getPrivateKey(): Promise<string | null> {
|
||||
try {
|
||||
return await getOrCreateSecret();
|
||||
const secret = await getOrCreateSecret();
|
||||
// Ensure the secret is 0x-prefixed for components expecting hex strings
|
||||
return secret.startsWith('0x') ? secret : `0x${secret}`;
|
||||
} catch (error) {
|
||||
console.error('Failed to get/create secret:', error);
|
||||
return null;
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { ActivityIndicator, Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import type { DocumentCatalog } from '@selfxyz/common/dist/esm/src/utils/types.js';
|
||||
// no direct SDK calls here
|
||||
import { extractNameFromDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import ScreenLayout from '../components/ScreenLayout';
|
||||
import { formatDataPreview, humanizeDocumentType, maskId } from '../utils/document';
|
||||
@@ -22,13 +22,63 @@ type Props = {
|
||||
// helpers moved to utils/document
|
||||
|
||||
export default function DocumentsList({ onBack, catalog }: Props) {
|
||||
const { documents, loading, error, deleting, deleteDocument, refresh } = useDocuments();
|
||||
const selfClient = useSelfClient();
|
||||
const { documents, loading, error, deleting, deleteDocument, refresh, clearing, clearAllDocuments } = useDocuments();
|
||||
const [documentNames, setDocumentNames] = useState<Record<string, { firstName: string; lastName: string }>>({});
|
||||
|
||||
// Refresh when catalog selection changes (e.g., after generation or external updates)
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [catalog.selectedDocumentId, refresh]);
|
||||
|
||||
// Load names for all documents
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadDocumentNames = async () => {
|
||||
const names: Record<string, { firstName: string; lastName: string }> = {};
|
||||
await Promise.all(
|
||||
documents.map(async doc => {
|
||||
const name = await extractNameFromDocument(selfClient, doc.metadata.id);
|
||||
if (name) {
|
||||
names[doc.metadata.id] = name;
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (!cancelled) {
|
||||
setDocumentNames(names);
|
||||
}
|
||||
};
|
||||
|
||||
if (documents.length === 0) {
|
||||
setDocumentNames({});
|
||||
return;
|
||||
}
|
||||
|
||||
loadDocumentNames();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [documents, selfClient]);
|
||||
|
||||
const handleClearAll = () => {
|
||||
Alert.alert('Clear All Documents', 'Are you sure you want to delete all documents? This action cannot be undone.', [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Clear All',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await clearAllDocuments();
|
||||
} catch (err) {
|
||||
Alert.alert('Error', `Failed to clear documents: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleDelete = async (documentId: string, documentType: string) => {
|
||||
Alert.alert('Delete Document', `Are you sure you want to delete this ${humanizeDocumentType(documentType)}?`, [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
@@ -68,10 +118,9 @@ export default function DocumentsList({ onBack, catalog }: Props) {
|
||||
if (documents.length === 0) {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyText}>No documents yet</Text>
|
||||
<Text style={styles.emptyText}>No documents</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Generate a mock document to see it appear here. The demo document store keeps everything locally on your
|
||||
device.
|
||||
Generate a mock document or scan a real document to see it appear here.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -83,11 +132,16 @@ export default function DocumentsList({ onBack, catalog }: Props) {
|
||||
const preview = formatDataPreview(metadata);
|
||||
const documentId = maskId(metadata.id);
|
||||
const isDeleting = deleting === metadata.id;
|
||||
const nameData = documentNames[metadata.id];
|
||||
const fullName = nameData ? `${nameData.firstName} ${nameData.lastName}`.trim() : null;
|
||||
|
||||
return (
|
||||
<View key={metadata.id} style={styles.documentCard}>
|
||||
<View style={styles.documentHeader}>
|
||||
<Text style={styles.documentType}>{humanizeDocumentType(metadata.documentType)}</Text>
|
||||
<View style={styles.documentTitleContainer}>
|
||||
<Text style={styles.documentType}>{humanizeDocumentType(metadata.documentType)}</Text>
|
||||
{fullName && <Text style={styles.documentName}>{fullName}</Text>}
|
||||
</View>
|
||||
<View style={styles.headerRight}>
|
||||
<View style={[styles.statusBadge, badgeStyle]}>
|
||||
<Text style={styles.statusText}>{statusLabel}</Text>
|
||||
@@ -105,7 +159,6 @@ export default function DocumentsList({ onBack, catalog }: Props) {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.documentMeta}>{(metadata.documentCategory ?? 'unknown').toUpperCase()}</Text>
|
||||
<Text style={styles.documentMeta}>{metadata.mock ? 'Mock data' : 'Live data'}</Text>
|
||||
<Text style={styles.documentPreview} selectable>
|
||||
{preview}
|
||||
@@ -115,10 +168,24 @@ export default function DocumentsList({ onBack, catalog }: Props) {
|
||||
</View>
|
||||
);
|
||||
});
|
||||
}, [documents, error, loading, deleting]);
|
||||
}, [documents, error, loading, deleting, documentNames]);
|
||||
|
||||
const clearButton = (
|
||||
<TouchableOpacity
|
||||
style={[styles.clearButton, (clearing || documents.length === 0) && styles.disabledButton]}
|
||||
onPress={handleClearAll}
|
||||
disabled={clearing || documents.length === 0}
|
||||
>
|
||||
{clearing ? (
|
||||
<ActivityIndicator size="small" color="#dc3545" />
|
||||
) : (
|
||||
<Text style={styles.clearButtonText}>Clear All</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScreenLayout title="My Documents" onBack={onBack}>
|
||||
<ScreenLayout title="My Documents" onBack={onBack} rightAction={clearButton}>
|
||||
{content}
|
||||
</ScreenLayout>
|
||||
);
|
||||
@@ -131,6 +198,33 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 20,
|
||||
},
|
||||
headerContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginBottom: 10,
|
||||
},
|
||||
clearButton: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#ffeef0',
|
||||
borderWidth: 1,
|
||||
borderColor: '#dc3545',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 30,
|
||||
minWidth: 80,
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
clearButtonText: {
|
||||
color: '#dc3545',
|
||||
fontWeight: '600',
|
||||
fontSize: 14,
|
||||
},
|
||||
disabledButton: {
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderColor: '#e1e5e9',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
@@ -153,11 +247,20 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 8,
|
||||
},
|
||||
documentTitleContainer: {
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
documentType: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
flex: 1,
|
||||
},
|
||||
documentName: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
color: '#0550ae',
|
||||
marginTop: 2,
|
||||
},
|
||||
headerRight: {
|
||||
alignItems: 'flex-end',
|
||||
|
||||
@@ -3,25 +3,20 @@
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ActivityIndicator, Button, Platform, StyleSheet, Switch, Text, TextInput, View } from 'react-native';
|
||||
import { ActivityIndicator, Alert, Button, Platform, StyleSheet, Switch, Text, TextInput, View } from 'react-native';
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { calculateContentHash, countryCodes, inferDocumentCategory, isMRZDocument } from '@selfxyz/common';
|
||||
import { calculateContentHash, inferDocumentCategory, isMRZDocument } from '@selfxyz/common';
|
||||
import type { DocumentMetadata, IDDocument } from '@selfxyz/common/dist/esm/src/utils/types.js';
|
||||
import {
|
||||
generateMockDocument,
|
||||
signatureAlgorithmToStrictSignatureAlgorithm,
|
||||
useSelfClient,
|
||||
} from '@selfxyz/mobile-sdk-alpha';
|
||||
import { generateMockDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import { Picker } from '@react-native-picker/picker';
|
||||
import Icon from 'react-native-vector-icons/Ionicons';
|
||||
import SafeAreaScrollView from '../components/SafeAreaScrollView';
|
||||
import StandardHeader from '../components/StandardHeader';
|
||||
import { AlgorithmCountryFields } from '../components/AlgorithmCountryFields';
|
||||
import { PickerField } from '../components/PickerField';
|
||||
|
||||
const algorithmOptions = Object.keys(signatureAlgorithmToStrictSignatureAlgorithm);
|
||||
const documentTypeOptions = ['mock_passport', 'mock_id_card', 'mock_aadhaar'] as const;
|
||||
const countryOptions = Object.keys(countryCodes);
|
||||
const documentTypePickerItems = documentTypeOptions.map(dt => ({ label: dt, value: dt }));
|
||||
|
||||
const defaultAge = '21';
|
||||
const defaultExpiryYears = '5';
|
||||
@@ -68,6 +63,9 @@ export default function GenerateMock({ onDocumentStored, onNavigate, onBack }: P
|
||||
const handleGenerate = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Force React to render the loading state before starting async work
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const ageNum = Number(age);
|
||||
const expiryNum = Number(expiryYears);
|
||||
@@ -111,8 +109,23 @@ export default function GenerateMock({ onDocumentStored, onNavigate, onBack }: P
|
||||
catalog.selectedDocumentId = documentId;
|
||||
await selfClient.saveDocumentCatalog(catalog);
|
||||
await onDocumentStored?.();
|
||||
// Auto-navigate to register screen after successful generation
|
||||
onNavigate('register');
|
||||
|
||||
// Refresh first and last name with new random values after successful generation
|
||||
setFirstName(getRandomFirstName());
|
||||
setLastName(getRandomLastName());
|
||||
|
||||
// Ensure minimum loading display time (500ms) for better UX
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed < 500) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500 - elapsed));
|
||||
}
|
||||
|
||||
// Auto-navigate to register screen only if it's the first document
|
||||
if (catalog.documents.length === 1) {
|
||||
onNavigate('register');
|
||||
} else {
|
||||
Alert.alert('Success', 'Mock document generated successfully.');
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
@@ -161,65 +174,19 @@ export default function GenerateMock({ onDocumentStored, onNavigate, onBack }: P
|
||||
ios_backgroundColor="#d1d5db"
|
||||
/>
|
||||
</View>
|
||||
{documentType !== 'mock_aadhaar' && (
|
||||
<>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Algorithm</Text>
|
||||
<View style={styles.pickerContainer}>
|
||||
<Picker
|
||||
selectedValue={algorithm}
|
||||
onValueChange={(itemValue: string) => setAlgorithm(itemValue)}
|
||||
style={styles.picker}
|
||||
>
|
||||
{algorithmOptions.map(alg => (
|
||||
<Picker.Item label={alg} value={alg} key={alg} />
|
||||
))}
|
||||
</Picker>
|
||||
{Platform.OS === 'ios' && (
|
||||
<Icon name="chevron-down-outline" size={20} color="#000" style={styles.pickerIcon} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Country</Text>
|
||||
<View style={styles.pickerContainer}>
|
||||
<Picker
|
||||
selectedValue={country}
|
||||
onValueChange={(itemValue: string) => setCountry(itemValue)}
|
||||
style={styles.picker}
|
||||
>
|
||||
{countryOptions.map(code => (
|
||||
<Picker.Item
|
||||
label={`${code} - ${countryCodes[code as keyof typeof countryCodes]}`}
|
||||
value={code}
|
||||
key={code}
|
||||
/>
|
||||
))}
|
||||
</Picker>
|
||||
{Platform.OS === 'ios' && (
|
||||
<Icon name="chevron-down-outline" size={20} color="#000" style={styles.pickerIcon} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Document Type</Text>
|
||||
<View style={styles.pickerContainer}>
|
||||
<Picker
|
||||
selectedValue={documentType}
|
||||
onValueChange={(itemValue: string) => setDocumentType(itemValue as (typeof documentTypeOptions)[number])}
|
||||
style={styles.picker}
|
||||
>
|
||||
{documentTypeOptions.map(dt => (
|
||||
<Picker.Item label={dt} value={dt} key={dt} />
|
||||
))}
|
||||
</Picker>
|
||||
{Platform.OS === 'ios' && (
|
||||
<Icon name="chevron-down-outline" size={20} color="#000" style={styles.pickerIcon} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<AlgorithmCountryFields
|
||||
show={documentType !== 'mock_aadhaar'}
|
||||
algorithm={algorithm}
|
||||
setAlgorithm={setAlgorithm}
|
||||
country={country}
|
||||
setCountry={setCountry}
|
||||
/>
|
||||
<PickerField
|
||||
label="Document Type"
|
||||
selectedValue={documentType}
|
||||
onValueChange={(itemValue: string) => setDocumentType(itemValue as (typeof documentTypeOptions)[number])}
|
||||
items={documentTypePickerItems}
|
||||
/>
|
||||
<View style={styles.buttonRow}>
|
||||
<View style={styles.buttonWrapper}>
|
||||
<Button title="Reset" onPress={reset} color={Platform.OS === 'ios' ? '#007AFF' : undefined} />
|
||||
@@ -233,7 +200,11 @@ export default function GenerateMock({ onDocumentStored, onNavigate, onBack }: P
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{loading && <ActivityIndicator style={styles.spinner} size="large" color="#0000ff" />}
|
||||
{loading && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#0550ae" />
|
||||
</View>
|
||||
)}
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
</SafeAreaScrollView>
|
||||
);
|
||||
@@ -272,36 +243,6 @@ const styles = StyleSheet.create({
|
||||
marginVertical: 6,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
pickerContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#ccc',
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
picker: {
|
||||
flex: 1,
|
||||
color: '#000',
|
||||
...Platform.select({
|
||||
ios: {
|
||||
height: 40,
|
||||
},
|
||||
android: {
|
||||
height: 40,
|
||||
},
|
||||
}),
|
||||
},
|
||||
pickerIcon: {
|
||||
position: 'absolute',
|
||||
right: 12,
|
||||
top: 10,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
top: 10,
|
||||
},
|
||||
}),
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
@@ -311,6 +252,15 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
marginHorizontal: 6,
|
||||
},
|
||||
spinner: { marginVertical: 16 },
|
||||
loadingContainer: {
|
||||
marginVertical: 20,
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 14,
|
||||
color: '#0550ae',
|
||||
fontWeight: '500',
|
||||
},
|
||||
error: { color: 'red', marginTop: 12, textAlign: 'center', fontSize: 14 },
|
||||
});
|
||||
|
||||
@@ -6,9 +6,9 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ActivityIndicator, Alert, Button, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import type { DocumentCatalog, IDDocument } from '@selfxyz/common/dist/esm/src/utils/types.js';
|
||||
import { extractNameFromMRZ, getAllDocuments, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
import { extractNameFromDocument, getAllDocuments, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
import { PickerField } from '../components/PickerField';
|
||||
|
||||
import { Picker } from '@react-native-picker/picker';
|
||||
import ScreenLayout from '../components/ScreenLayout';
|
||||
import LogsPanel from '../components/LogsPanel';
|
||||
import { useRegistration } from '../hooks/useRegistration';
|
||||
@@ -36,13 +36,12 @@ export default function RegisterDocument({ catalog, onBack, onSuccess }: Props)
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [selectedDocumentId, setSelectedDocumentId] = useState<string>(catalog.selectedDocumentId || '');
|
||||
const [selectedDocumentId, setSelectedDocumentId] = useState<string>('');
|
||||
const [selectedDocument, setSelectedDocument] = useState<IDDocument | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const registering = regState.registering;
|
||||
const statusMessage = regState.statusMessage;
|
||||
const detailedLogs = regState.logs;
|
||||
const showLogs = regState.showLogs;
|
||||
const [documentNames, setDocumentNames] = useState<Record<string, { firstName: string; lastName: string }>>({});
|
||||
|
||||
// Refresh catalog helper
|
||||
const refreshCatalog = useCallback(async () => {
|
||||
@@ -58,12 +57,52 @@ export default function RegisterDocument({ catalog, onBack, onSuccess }: Props)
|
||||
}
|
||||
}, [selfClient, onSuccess]);
|
||||
|
||||
// Update selected document when catalog changes (e.g., after generating a new mock)
|
||||
// Auto-select first available unregistered document (newest first)
|
||||
useEffect(() => {
|
||||
if (catalog.selectedDocumentId && catalog.selectedDocumentId !== selectedDocumentId) {
|
||||
setSelectedDocumentId(catalog.selectedDocumentId);
|
||||
const availableDocuments = (catalog.documents || []).filter(doc => !doc.isRegistered).reverse();
|
||||
const firstUnregisteredDocId = availableDocuments[0]?.id;
|
||||
|
||||
if (firstUnregisteredDocId && !selectedDocumentId) {
|
||||
setSelectedDocumentId(firstUnregisteredDocId);
|
||||
}
|
||||
}, [catalog.selectedDocumentId, selectedDocumentId]);
|
||||
}, [catalog.documents, selectedDocumentId]);
|
||||
|
||||
// Auto-select when catalog changes and current selection is no longer available
|
||||
useEffect(() => {
|
||||
const availableDocuments = (catalog.documents || []).filter(doc => !doc.isRegistered).reverse();
|
||||
const isCurrentSelectionAvailable = availableDocuments.some(doc => doc.id === selectedDocumentId);
|
||||
|
||||
if (!isCurrentSelectionAvailable && availableDocuments.length > 0) {
|
||||
setSelectedDocumentId(availableDocuments[0].id);
|
||||
}
|
||||
}, [catalog.documents, selectedDocumentId]);
|
||||
|
||||
// Load names for all documents in the catalog
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadDocumentNames = async () => {
|
||||
const names: Record<string, { firstName: string; lastName: string }> = {};
|
||||
await Promise.all(
|
||||
(catalog.documents || []).map(async doc => {
|
||||
if (doc.isRegistered) return;
|
||||
const name = await extractNameFromDocument(selfClient, doc.id);
|
||||
if (name) {
|
||||
names[doc.id] = name;
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (!cancelled) {
|
||||
setDocumentNames(names);
|
||||
}
|
||||
};
|
||||
|
||||
loadDocumentNames();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [catalog.documents, selfClient]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSelectedDocument = async () => {
|
||||
@@ -87,40 +126,29 @@ export default function RegisterDocument({ catalog, onBack, onSuccess }: Props)
|
||||
loadSelectedDocument();
|
||||
}, [selectedDocumentId, selfClient]);
|
||||
|
||||
// Monitor completion and errors for dialogs
|
||||
// One-shot completion handler to avoid repeated alerts
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
if (!registering && regState.statusMessage.startsWith('🎉')) {
|
||||
timeoutId = setTimeout(async () => {
|
||||
// Guard against updates after unmount or during a new registration attempt
|
||||
if (mounted.current && !registering && regState.statusMessage.startsWith('🎉')) {
|
||||
await refreshCatalog();
|
||||
Alert.alert(
|
||||
'Success! 🎉',
|
||||
`Your ${selectedDocument?.mock ? 'mock ' : ''}document has been registered on-chain!`,
|
||||
[
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => {
|
||||
if (mounted.current) {
|
||||
setSelectedDocumentId('');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Cleanup the timeout if the component unmounts or dependencies change
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [registering, regState.statusMessage, selectedDocument, refreshCatalog]);
|
||||
actions.setOnComplete(async () => {
|
||||
if (!mounted.current) return;
|
||||
await refreshCatalog();
|
||||
Alert.alert(
|
||||
'Success! 🎉',
|
||||
`Your ${selectedDocument?.mock ? 'mock ' : ''}document has been registered on-chain!`,
|
||||
[
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => {
|
||||
if (mounted.current) {
|
||||
setSelectedDocumentId('');
|
||||
actions.reset();
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
return () => actions.setOnComplete(null);
|
||||
}, [actions, selectedDocument, refreshCatalog]);
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!selectedDocument || !selectedDocumentId) return;
|
||||
@@ -138,37 +166,37 @@ export default function RegisterDocument({ catalog, onBack, onSuccess }: Props)
|
||||
|
||||
// Filter to only unregistered documents and sort newest first
|
||||
const availableDocuments = (catalog.documents || []).filter(doc => !doc.isRegistered).reverse();
|
||||
const firstAvailableDocId = availableDocuments[0]?.id || '';
|
||||
const selectedIdForPicker = selectedDocumentId || firstAvailableDocId || '';
|
||||
|
||||
return (
|
||||
<ScreenLayout title="Register Document [WiP]" onBack={onBack}>
|
||||
<ScreenLayout title="Register Document" onBack={onBack}>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.pickerContainer}>
|
||||
<Text style={styles.label}>Select Document</Text>
|
||||
<View style={styles.pickerWrapper}>
|
||||
<Picker
|
||||
selectedValue={selectedDocumentId}
|
||||
onValueChange={(itemValue: string) => setSelectedDocumentId(itemValue)}
|
||||
style={styles.picker}
|
||||
itemStyle={styles.pickerItem}
|
||||
enabled={!registering}
|
||||
>
|
||||
<Picker.Item label="Select a document..." value="" style={styles.pickerItem} />
|
||||
{availableDocuments.map(doc => {
|
||||
const nameData = extractNameFromMRZ(doc.data || '');
|
||||
const docType = humanizeDocumentType(doc.documentType);
|
||||
const docId = doc.id.slice(0, 8);
|
||||
{availableDocuments.length > 0 && (
|
||||
<PickerField
|
||||
label="Select Document"
|
||||
selectedValue={selectedIdForPicker}
|
||||
onValueChange={setSelectedDocumentId}
|
||||
enabled={!registering}
|
||||
items={
|
||||
!firstAvailableDocId
|
||||
? [{ label: 'Select a document...', value: '' }]
|
||||
: availableDocuments.map(doc => {
|
||||
const nameData = documentNames[doc.id];
|
||||
const docType = humanizeDocumentType(doc.documentType);
|
||||
const docId = doc.id.slice(0, 8);
|
||||
|
||||
let label = `${docType} - ${docId}...`;
|
||||
if (nameData) {
|
||||
const fullName = `${nameData.firstName} ${nameData.lastName}`.trim();
|
||||
label = fullName ? `${fullName} - ${docType} - ${docId}...` : label;
|
||||
}
|
||||
let label = `${docType} - ${docId}...`;
|
||||
if (nameData) {
|
||||
const fullName = `${nameData.firstName} ${nameData.lastName}`.trim();
|
||||
label = fullName ? `${fullName} - ${docType} - ${docId}...` : label;
|
||||
}
|
||||
|
||||
return <Picker.Item key={doc.id} label={label} value={doc.id} style={styles.pickerItem} />;
|
||||
})}
|
||||
</Picker>
|
||||
</View>
|
||||
</View>
|
||||
return { label, value: doc.id };
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<View style={styles.loadingContainer}>
|
||||
@@ -182,11 +210,11 @@ export default function RegisterDocument({ catalog, onBack, onSuccess }: Props)
|
||||
<Text style={styles.statusText}>{statusMessage}</Text>
|
||||
<Text style={styles.statusState}>State: {currentState}</Text>
|
||||
|
||||
<LogsPanel logs={detailedLogs} show={showLogs} onToggle={actions.toggleLogs} />
|
||||
<LogsPanel logs={regState.logs} show={regState.showLogs} onToggle={actions.toggleLogs} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{selectedDocument && !loading && (
|
||||
{selectedDocument && !loading && availableDocuments.length > 0 && (
|
||||
<>
|
||||
<View style={styles.documentSection}>
|
||||
<Text style={styles.documentTitle}>Document Data:</Text>
|
||||
@@ -213,7 +241,7 @@ export default function RegisterDocument({ catalog, onBack, onSuccess }: Props)
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!selectedDocumentId && availableDocuments.length === 0 && (
|
||||
{availableDocuments.length === 0 && (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyStateText}>
|
||||
No unregistered documents available. Generate a mock document to get started.
|
||||
@@ -235,28 +263,12 @@ const styles = StyleSheet.create({
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
pickerContainer: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
color: '#333',
|
||||
},
|
||||
pickerWrapper: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
picker: {
|
||||
height: 50,
|
||||
},
|
||||
pickerItem: {
|
||||
fontSize: 13,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -53,9 +53,10 @@ export const screenDescriptors: ScreenDescriptor[] = [
|
||||
sectionTitle: '⭐ Mock Documents',
|
||||
status: 'working',
|
||||
load: () => require('./RegisterDocument').default,
|
||||
getProps: ({ navigate, documentCatalog }) => ({
|
||||
getProps: ({ navigate, documentCatalog, refreshDocuments }) => ({
|
||||
catalog: documentCatalog,
|
||||
onBack: () => navigate('home'),
|
||||
onSuccess: refreshDocuments,
|
||||
}),
|
||||
},
|
||||
{
|
||||
|
||||
27
yarn.lock
27
yarn.lock
@@ -6254,16 +6254,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-native-picker/picker@npm:^2.11.1":
|
||||
version: 2.11.2
|
||||
resolution: "@react-native-picker/picker@npm:2.11.2"
|
||||
peerDependencies:
|
||||
react: "*"
|
||||
react-native: "*"
|
||||
checksum: 10c0/f9a6449797311367d52c5a0ef692fd76784ff151696db3702b21e8e94ddbd209494c0362281a55f197c43d5da67d5337046f95c4e79fdb63a493ff3565cb689e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-native/assets-registry@npm:0.76.9":
|
||||
version: 0.76.9
|
||||
resolution: "@react-native/assets-registry@npm:0.76.9"
|
||||
@@ -23825,7 +23815,6 @@ __metadata:
|
||||
"@noble/hashes": "npm:^1.5.0"
|
||||
"@react-native-async-storage/async-storage": "npm:^2.2.0"
|
||||
"@react-native-community/cli": "npm:^16.0.3"
|
||||
"@react-native-picker/picker": "npm:^2.11.1"
|
||||
"@react-native/gradle-plugin": "npm:0.76.9"
|
||||
"@react-native/metro-config": "npm:0.76.9"
|
||||
"@selfxyz/common": "workspace:*"
|
||||
@@ -23857,7 +23846,7 @@ __metadata:
|
||||
react-native-get-random-values: "npm:^1.11.0"
|
||||
react-native-keychain: "npm:^10.0.0"
|
||||
react-native-safe-area-context: "npm:^5.6.1"
|
||||
react-native-svg: "npm:^15.13.0"
|
||||
react-native-svg: "npm:15.12.1"
|
||||
react-native-svg-transformer: "npm:^1.5.1"
|
||||
react-native-vector-icons: "npm:^10.3.0"
|
||||
stream-browserify: "npm:^3.0.0"
|
||||
@@ -26564,20 +26553,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-native-svg@npm:^15.13.0":
|
||||
version: 15.13.0
|
||||
resolution: "react-native-svg@npm:15.13.0"
|
||||
dependencies:
|
||||
css-select: "npm:^5.1.0"
|
||||
css-tree: "npm:^1.1.3"
|
||||
warn-once: "npm:0.1.1"
|
||||
peerDependencies:
|
||||
react: "*"
|
||||
react-native: "*"
|
||||
checksum: 10c0/89ba2ed640f78e69d44980473530fdb83e24336a1a43936e19f532e772c41e8532ee1d4f3ae003339e817a3e3b055aa2a8ee3c0da31754cd2c83fa1a81130d6a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-native-vector-icons@npm:^10.3.0":
|
||||
version: 10.3.0
|
||||
resolution: "react-native-vector-icons@npm:10.3.0"
|
||||
|
||||
Reference in New Issue
Block a user