Three/country picker (#1448)

* use 3.0 country picker

* get blurview working in app
add navigation adapter to sdk
render

* fix fonts and double view registration issues

* dont need this script as we use peer deps now

* fix our package installs





* prayed to the false idol of claude to resolve installing anon-aadhar from a specific commit from a monorepo

* fix route types


* add peer deps to demo

---------

Co-authored-by: Leszek Stachowski <leszek.stachowski@self.xyz>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Aaron DeRuvo
2025-12-01 16:08:09 +01:00
committed by GitHub
parent d5d0879045
commit 7899c239cc
20 changed files with 245 additions and 181 deletions

View File

@@ -151,6 +151,7 @@
"dependencies": {
"@babel/runtime": "^7.28.3",
"@selfxyz/common": "workspace:^",
"@selfxyz/euclid": "^0.4.1",
"@xstate/react": "^5.0.5",
"node-forge": "^1.3.1",
"react-native-nfc-manager": "^3.17.1",
@@ -191,9 +192,11 @@
"lottie-react-native": "7.2.2",
"react": "^18.3.1",
"react-native": "0.76.9",
"react-native-blur-effect": "^1.1.3",
"react-native-haptic-feedback": "*",
"react-native-localize": "*",
"react-native-svg": "*"
"react-native-svg": "*",
"react-native-webview": "^13.16.0"
},
"packageManager": "yarn@4.6.0",
"publishConfig": {

View File

@@ -42,7 +42,7 @@ const optionalDefaults: Required<Pick<Adapters, 'clock' | 'logger'>> = {
},
};
const REQUIRED_ADAPTERS = ['auth', 'scanner', 'network', 'crypto', 'documents'] as const;
const REQUIRED_ADAPTERS = ['auth', 'scanner', 'network', 'crypto', 'documents', 'navigation'] as const;
export const createListenersMap = (): {
map: Map<SDKEvent, Set<(p: any) => void>>;
@@ -212,7 +212,12 @@ export function createSelfClient({
getMRZState: () => {
return useMRZStore.getState();
},
goBack: () => {
adapters.navigation.goBack();
},
goTo: (routeName, params) => {
adapters.navigation.goTo(routeName, params);
},
// for reactivity (if needed)
useProvingStore,
useSelfAppStore,

View File

@@ -2,67 +2,26 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { memo, useCallback } from 'react';
import { ActivityIndicator, FlatList, StyleSheet, TouchableOpacity, View } from 'react-native';
import { useCallback, useState } from 'react';
import { commonNames } from '@selfxyz/common/constants/countries';
import { CountryPickerScreen as CountryPickerUI } from '@selfxyz/euclid';
import { BodyText, RoundFlag, XStack, YStack } from '../../components';
import { black, slate100, slate500 } from '../../constants/colors';
import { advercase, dinot } from '../../constants/fonts';
import { RoundFlag } from '../../components';
import { useSelfClient } from '../../context';
import { useCountries } from '../../documents/useCountries';
import { buttonTap } from '../../haptic';
import { SdkEvents } from '../../types/events';
interface CountryListItem {
key: string;
countryCode: string;
}
const ITEM_HEIGHT = 65;
const FLAG_SIZE = 32;
const CountryItem = memo<{
countryCode: string;
onSelect: (code: string) => void;
}>(({ countryCode, onSelect }) => {
const countryName = commonNames[countryCode as keyof typeof commonNames];
if (!countryName) return null;
return (
<TouchableOpacity onPress={() => onSelect(countryCode)} style={styles.countryItemContainer}>
<XStack style={styles.countryItemContent}>
<RoundFlag countryCode={countryCode} size={FLAG_SIZE} />
<BodyText style={styles.countryItemText}>{countryName}</BodyText>
</XStack>
</TouchableOpacity>
);
});
CountryItem.displayName = 'CountryItem';
const Loading = () => (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" />
</View>
);
Loading.displayName = 'Loading';
const CountryPickerScreen: React.FC = () => {
const selfClient = useSelfClient();
const [searchValue, setSearchValue] = useState('');
const { countryData, countryList, loading, userCountryCode, showSuggestion } = useCountries();
const onPressCountry = useCallback(
const onCountrySelect = useCallback(
(countryCode: string) => {
buttonTap();
// if (__DEV__) {
// console.log('Selected country code:', countryCode);
// console.log('Current countryData:', countryData);
// console.log('Available country codes:', Object.keys(countryData));
// }
const documentTypes = countryData[countryCode];
if (__DEV__) {
console.log('documentTypes for', countryCode, ':', documentTypes);
@@ -87,105 +46,34 @@ const CountryPickerScreen: React.FC = () => {
[countryData, selfClient],
);
const renderItem = useCallback(
({ item }: { item: CountryListItem }) => <CountryItem countryCode={item.countryCode} onSelect={onPressCountry} />,
[onPressCountry],
);
const renderFlag = useCallback((countryCode: string, size: number) => {
return <RoundFlag countryCode={countryCode} size={size} />;
}, []);
const keyExtractor = useCallback((item: CountryListItem) => item.countryCode, []);
const getCountryName = useCallback((countryCode: string) => {
return commonNames[countryCode as keyof typeof commonNames] || countryCode;
}, []);
const getItemLayout = useCallback(
(_data: ArrayLike<CountryListItem> | null | undefined, index: number) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
}),
[],
);
const onSearchChange = useCallback((value: string) => {
setSearchValue(value);
}, []);
return (
<YStack flex={1} paddingTop="$4" paddingHorizontal="$4" backgroundColor={slate100}>
<YStack marginTop="$4" marginBottom="$6">
<BodyText style={styles.titleText}>Select the country that issued your ID</BodyText>
<BodyText style={styles.subtitleText}>
Self has support for over 300 ID types. You can select the type of ID in the next step
</BodyText>
</YStack>
{loading ? (
<Loading />
) : (
<YStack flex={1}>
{showSuggestion && (
<YStack marginBottom="$2">
<BodyText style={styles.sectionLabel}>SUGGESTION</BodyText>
<CountryItem
countryCode={userCountryCode as string /*safe due to showSuggestion*/}
onSelect={onPressCountry}
/>
<BodyText style={styles.sectionLabelBottom}>SELECT AN ISSUING COUNTRY</BodyText>
</YStack>
)}
<FlatList
data={countryList}
renderItem={renderItem}
keyExtractor={keyExtractor}
showsVerticalScrollIndicator={false}
removeClippedSubviews={true}
maxToRenderPerBatch={10}
windowSize={10}
initialNumToRender={10}
updateCellsBatchingPeriod={50}
getItemLayout={getItemLayout}
/>
</YStack>
)}
</YStack>
<CountryPickerUI
isLoading={loading}
countries={countryList}
onCountrySelect={onCountrySelect}
suggestionCountryCode={userCountryCode ?? undefined}
showSuggestion={!!showSuggestion}
renderFlag={renderFlag}
getCountryName={getCountryName}
searchValue={searchValue}
onClose={selfClient.goBack}
onInfoPress={() => console.log('Info pressed TODO: Implement')}
onSearchChange={onSearchChange}
/>
);
};
CountryPickerScreen.displayName = 'CountryPickerScreen';
const styles = StyleSheet.create({
countryItemContainer: {
paddingVertical: 13,
},
countryItemContent: {
alignItems: 'center',
gap: 16,
},
countryItemText: {
fontSize: 16,
color: black,
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
titleText: {
fontSize: 29,
fontFamily: advercase,
color: black,
},
subtitleText: {
fontSize: 16,
color: slate500,
marginTop: 20,
},
sectionLabel: {
fontSize: 16,
color: black,
fontFamily: dinot,
letterSpacing: 0.8,
marginBottom: 8,
},
sectionLabelBottom: {
fontSize: 16,
color: black,
fontFamily: dinot,
letterSpacing: 0.8,
marginTop: 20,
},
});
export default CountryPickerScreen;

View File

@@ -17,8 +17,10 @@ export type {
MRZValidation,
NFCScanResult,
NFCScannerAdapter,
NavigationAdapter,
NetworkAdapter,
Progress,
RouteName,
SelfClient,
StorageAdapter,
TrackEventParams,

View File

@@ -199,6 +199,36 @@ export interface Adapters {
auth: AuthAdapter;
/** Required document persistence layer. Implementations must be idempotent. */
documents: DocumentsAdapter;
/** Required navigation adapter for handling screen transitions. */
navigation: NavigationAdapter;
}
/**
* Map these route names to your navigation configuration.
* Includes all screens that the SDK may navigate to across host applications.
*/
export type RouteName =
// Document acquisition flow
| 'DocumentCamera'
| 'DocumentOnboarding'
| 'CountryPicker'
| 'IDPicker'
| 'DocumentNFCScan'
| 'ManageDocuments'
// Account/onboarding flow
| 'Home'
| 'AccountVerifiedSuccess'
| 'AccountRecoveryChoice'
| 'SaveRecoveryPhrase'
// Error/fallback screens
| 'ComingSoon'
| 'DocumentDataNotFound'
// Settings
| 'Settings';
export interface NavigationAdapter {
goBack(): void;
goTo(routeName: RouteName, params?: Record<string, unknown>): void;
}
/**
@@ -284,6 +314,8 @@ export interface SelfClient {
scanNFC(opts: NFCScanOpts & { signal?: AbortSignal }): Promise<NFCScanResult>;
/** Parses MRZ text and returns structured fields plus checksum metadata. */
extractMRZInfo(mrz: string): MRZInfo;
goBack(): void;
goTo(routeName: RouteName, params?: Record<string, unknown>): void;
/**
* Convenience wrapper around {@link AnalyticsAdapter.trackEvent}. Calls are

View File

@@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { CryptoAdapter, DocumentsAdapter, NetworkAdapter, NFCScannerAdapter } from '../src';
import { createListenersMap, createSelfClient, SdkEvents } from '../src/index';
import type { AuthAdapter } from '../src/types/public';
import type { AuthAdapter, NavigationAdapter } from '../src/types/public';
describe('createSelfClient', () => {
// Test eager validation during client creation
@@ -27,21 +27,21 @@ describe('createSelfClient', () => {
it('throws when network adapter missing during creation', () => {
// @ts-expect-error -- missing adapters
expect(() => createSelfClient({ config: {}, adapters: { scanner, crypto, documents, auth } })).toThrow(
expect(() => createSelfClient({ config: {}, adapters: { scanner, crypto, documents, auth, navigation } })).toThrow(
'network adapter not provided',
);
});
it('throws when crypto adapter missing during creation', () => {
// @ts-expect-error -- missing adapters
expect(() => createSelfClient({ config: {}, adapters: { scanner, network, documents, auth } })).toThrow(
expect(() => createSelfClient({ config: {}, adapters: { scanner, network, documents, auth, navigation } })).toThrow(
'crypto adapter not provided',
);
});
it('throws when documents adapter missing during creation', () => {
// @ts-expect-error -- missing adapters
expect(() => createSelfClient({ config: {}, adapters: { scanner, network, crypto, auth } })).toThrow(
expect(() => createSelfClient({ config: {}, adapters: { scanner, network, crypto, auth, navigation } })).toThrow(
'documents adapter not provided',
);
});
@@ -49,7 +49,7 @@ describe('createSelfClient', () => {
it('creates client successfully with all required adapters', () => {
const client = createSelfClient({
config: {},
adapters: { scanner, network, crypto, documents, auth },
adapters: { scanner, network, crypto, documents, auth, navigation },
listeners: new Map(),
});
expect(client).toBeTruthy();
@@ -59,7 +59,7 @@ describe('createSelfClient', () => {
const scanMock = vi.fn().mockResolvedValue({ passportData: { mock: true } });
const client = createSelfClient({
config: {},
adapters: { scanner: { scan: scanMock }, network, crypto, documents, auth },
adapters: { scanner: { scan: scanMock }, network, crypto, documents, auth, navigation },
listeners: new Map(),
});
const result = await client.scanNFC({
@@ -85,7 +85,7 @@ describe('createSelfClient', () => {
const scanMock = vi.fn().mockRejectedValue(err);
const client = createSelfClient({
config: {},
adapters: { scanner: { scan: scanMock }, network, crypto, documents, auth },
adapters: { scanner: { scan: scanMock }, network, crypto, documents, auth, navigation },
listeners: new Map(),
});
await expect(
@@ -106,7 +106,7 @@ describe('createSelfClient', () => {
const client = createSelfClient({
config: {},
adapters: { scanner, network, crypto, documents, auth },
adapters: { scanner, network, crypto, documents, auth, navigation },
listeners: listeners.map,
});
@@ -134,7 +134,7 @@ describe('createSelfClient', () => {
it('parses MRZ via client', () => {
const client = createSelfClient({
config: {},
adapters: { scanner, network, crypto, documents, auth },
adapters: { scanner, network, crypto, documents, auth, navigation },
listeners: new Map(),
});
const sample = `P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<\nL898902C36UTO7408122F1204159ZE184226B<<<<<10`;
@@ -149,6 +149,7 @@ describe('createSelfClient', () => {
const client = createSelfClient({
config: {},
adapters: {
navigation,
scanner,
network,
crypto,
@@ -171,7 +172,7 @@ describe('createSelfClient', () => {
const getPrivateKey = vi.fn(() => Promise.resolve('stubbed-private-key'));
const client = createSelfClient({
config: {},
adapters: { scanner, network, crypto, documents, auth: { getPrivateKey } },
adapters: { scanner, network, crypto, documents, navigation, auth: { getPrivateKey } },
listeners: new Map(),
});
@@ -181,7 +182,7 @@ describe('createSelfClient', () => {
const getPrivateKey = vi.fn(() => Promise.resolve('stubbed-private-key'));
const client = createSelfClient({
config: {},
adapters: { scanner, network, crypto, documents, auth: { getPrivateKey } },
adapters: { scanner, network, crypto, documents, navigation, auth: { getPrivateKey } },
listeners: new Map(),
});
await expect(client.hasPrivateKey()).resolves.toBe(true);
@@ -222,3 +223,8 @@ const documents: DocumentsAdapter = {
saveDocument: async () => {},
deleteDocument: async () => {},
};
const navigation: NavigationAdapter = {
goBack: vi.fn(),
goTo: vi.fn(),
};

View File

@@ -35,6 +35,10 @@ const createMockSelfClientWithDocumentsAdapter = (documentsAdapter: DocumentsAda
}),
},
},
navigation: {
goBack: () => {},
goTo: (_routeName: string, _params?: Record<string, any>) => {},
},
scanner: {
scan: async () => ({
passportData: {

View File

@@ -3,6 +3,8 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
/* eslint-disable sort-exports/sort-exports */
import type { NavigationAdapter } from 'src/types/public';
import type { CryptoAdapter, DocumentsAdapter, NetworkAdapter, NFCScannerAdapter } from '../../src';
// Shared test data
@@ -60,12 +62,18 @@ const mockAuth = {
getPrivateKey: async () => 'stubbed-private-key',
};
const mockNavigation: NavigationAdapter = {
goBack: vi.fn(),
goTo: vi.fn(),
};
export const mockAdapters = {
scanner: mockScanner,
network: mockNetwork,
crypto: mockCrypto,
documents: mockDocuments,
auth: mockAuth,
navigation: mockNavigation,
};
// Shared test expectations

View File

@@ -42,6 +42,7 @@
"lottie-react-native": "7.2.2",
"react": "^18.3.1",
"react-native": "0.76.9",
"react-native-blur-effect": "1.1.3",
"react-native-get-random-values": "^1.11.0",
"react-native-haptic-feedback": "^2.3.3",
"react-native-keychain": "^10.0.0",
@@ -49,6 +50,7 @@
"react-native-safe-area-context": "^5.6.1",
"react-native-svg": "15.12.1",
"react-native-vector-icons": "^10.3.0",
"react-native-webview": "13.16.0",
"stream-browserify": "^3.0.0",
"util": "^0.12.5"
},

View File

@@ -11,6 +11,7 @@ import {
createListenersMap,
SdkEvents,
type Adapters,
type RouteName,
type TrackEventParams,
type WsConn,
reactNativeScannerAdapter,
@@ -18,8 +19,40 @@ import {
import { persistentDocumentsAdapter } from '../utils/documentStore';
import { getOrCreateSecret } from '../utils/secureStorage';
import type { ScreenName } from '../navigation/NavigationProvider';
import { useNavigation } from '../navigation/NavigationProvider';
/**
* Maps SDK RouteName values to demo app ScreenName values.
* Routes not in this map are not supported in the demo app.
*/
const ROUTE_TO_SCREEN_MAP: Partial<Record<RouteName, ScreenName>> = {
'Home': 'Home',
'CountryPicker': 'CountrySelection',
'IDPicker': 'IDSelection',
'DocumentCamera': 'MRZ',
'DocumentNFCScan': 'NFC',
'ManageDocuments': 'Documents',
'AccountVerifiedSuccess': 'Success',
// Routes not implemented in demo app:
// 'DocumentOnboarding': null,
// 'SaveRecoveryPhrase': null,
// 'AccountRecoveryChoice': null,
// 'ComingSoon': null,
// 'DocumentDataNotFound': null,
// 'Settings': null,
} as const;
/**
* Translates SDK RouteName to demo app ScreenName.
*
* @param routeName - The route name from the SDK
* @returns The corresponding demo app screen name, or null if not supported
*/
function translateRouteToScreen(routeName: RouteName): ScreenName | null {
return ROUTE_TO_SCREEN_MAP[routeName] ?? null;
}
const createFetch = () => {
const fetchImpl = globalThis.fetch;
if (!fetchImpl) {
@@ -129,6 +162,21 @@ export function SelfClientProvider({ children, onNavigate }: SelfClientProviderP
},
ws: createWsAdapter(),
},
navigation: {
goBack: () => {
navigation.goBack();
},
goTo: (routeName, params) => {
const screenName = translateRouteToScreen(routeName);
if (screenName) {
// SDK passes generic Record<string, unknown>, but demo navigation expects specific types
// This is safe because we control the route mapping
navigation.navigate(screenName, params as any);
} else {
console.warn(`[SelfClientProvider] SDK route "${routeName}" is not mapped to a demo screen. Ignoring navigation request.`);
}
},
},
documents: persistentDocumentsAdapter,
crypto: {
async hash(data: Uint8Array): Promise<Uint8Array> {