SELF-1754: Implement selective disclosure on Proving Screen (#1549)

* add document selector test screen

* clean up mock docs

* update selection options

* Add DocumentSelectorForProving screen and route proof flows through it (#1555)

* Add document selector to proving flow

* fix formatting

* improvements

* redirect user to document not found screen when no documents

* option flow tweaks and tests

* wip tweaks

* fix scrollview bottom padding (#1556)

* tighten up selection text

* create inerstitial

* save wip

* remove not accepted state

* save wip design

* formatting

* update design

* update layout

* Update proving flow tests (#1559)

* Refactor ProveScreen to ProofRequestCard layout and preserve scroll position (#1560)

* Refactor prove screen layout

* fix: amount of hooks rendered needs to be the same for all variants

* long URL ellipsis

* keep titles consistent

* lint

---------

Co-authored-by: Leszek Stachowski <leszek.stachowski@self.xyz>

* wip fix tests

* fix tests

* formatting

* agent feedback

* fix tests

* save wip

* remove text

* fix types

* save working header update

* no transition

* cache document load for proving flow

* save fixes

* small fixes

* match disclosure text

* design updates

* fix approve flow

* fix document type flash

* add min height so text doesn't jump

* update lock

* formatting

* save refactor wip

* don't enable euclid yet

* fix tests

* fix staleness check

* fix select box description

* remove id selector screen

* vertically center

* button updates

* Remove proving document cache (#1567)

* formatting

---------

Co-authored-by: Leszek Stachowski <leszek.stachowski@self.xyz>
This commit is contained in:
Justin Hernandez
2026-01-09 13:56:10 -08:00
committed by GitHub
parent 1e44dc9c8d
commit 850e3b98f9
51 changed files with 4446 additions and 381 deletions

View File

@@ -5,45 +5,24 @@
import React from 'react';
import { XStack, YStack } from 'tamagui';
import type { Country3LetterCode } from '@selfxyz/common/constants';
import { countryCodes } from '@selfxyz/common/constants';
import type { SelfAppDisclosureConfig } from '@selfxyz/common/utils';
import { BodyText } from '@selfxyz/mobile-sdk-alpha/components';
import { slate200, slate500 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import CheckMark from '@/assets/icons/checkmark.svg';
import {
getDisclosureText,
ORDERED_DISCLOSURE_KEYS,
} from '@/utils/disclosureUtils';
interface DisclosureProps {
disclosures: SelfAppDisclosureConfig;
}
function listToString(list: string[]): string {
if (list.length === 1) {
return list[0];
} else if (list.length === 2) {
return list.join(' nor ');
}
return `${list.slice(0, -1).join(', ')} nor ${list.at(-1)}`;
}
export default function Disclosures({ disclosures }: DisclosureProps) {
// Define the order in which disclosures should appear.
const ORDERED_KEYS: Array<keyof SelfAppDisclosureConfig> = [
'issuing_state',
'name',
'passport_number',
'nationality',
'date_of_birth',
'gender',
'expiry_date',
'ofac',
'excludedCountries',
'minimumAge',
] as const;
return (
<YStack>
{ORDERED_KEYS.map(key => {
{ORDERED_DISCLOSURE_KEYS.map(key => {
const isEnabled = disclosures[key];
if (
!isEnabled ||
@@ -52,53 +31,17 @@ export default function Disclosures({ disclosures }: DisclosureProps) {
return null;
}
let text = '';
switch (key) {
case 'ofac':
text = 'I am not on the OFAC sanction list';
break;
case 'excludedCountries':
text = `I am not a citizen of the following countries: ${countriesToSentence(
(disclosures.excludedCountries as Country3LetterCode[]) || [],
)}`;
break;
case 'minimumAge':
text = `Age is over ${disclosures.minimumAge}`;
break;
case 'name':
text = 'Name';
break;
case 'passport_number':
text = 'Passport Number';
break;
case 'date_of_birth':
text = 'Date of Birth';
break;
case 'gender':
text = 'Gender';
break;
case 'expiry_date':
text = 'Passport Expiry Date';
break;
case 'issuing_state':
text = 'Issuing State';
break;
case 'nationality':
text = 'Nationality';
break;
default:
return null;
const text = getDisclosureText(key, disclosures);
if (!text) {
return null;
}
return <DisclosureItem key={key} text={text} />;
})}
</YStack>
);
}
function countriesToSentence(countries: Array<Country3LetterCode>): string {
return listToString(countries.map(country => countryCodes[country]));
}
interface DisclosureItemProps {
text: string;
}

View File

@@ -0,0 +1,132 @@
// 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 { Pressable } from 'react-native';
import { Separator, Text, View, XStack, YStack } from 'tamagui';
import { Check } from '@tamagui/lucide-icons';
import {
black,
green500,
green600,
iosSeparator,
slate200,
slate300,
slate400,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
export interface IDSelectorItemProps {
documentName: string;
state: IDSelectorState;
onPress?: () => void;
disabled?: boolean;
isLastItem?: boolean;
testID?: string;
}
export type IDSelectorState = 'active' | 'verified' | 'expired' | 'mock';
function getSubtitleText(state: IDSelectorState): string {
switch (state) {
case 'active':
return 'Currently active';
case 'verified':
return 'Verified ID';
case 'expired':
return 'Expired';
case 'mock':
return 'Testing document';
}
}
function getSubtitleColor(state: IDSelectorState): string {
switch (state) {
case 'active':
return green600;
case 'verified':
return slate400;
case 'expired':
return slate400;
case 'mock':
return slate400;
}
}
export const IDSelectorItem: React.FC<IDSelectorItemProps> = ({
documentName,
state,
onPress,
disabled,
isLastItem,
testID,
}) => {
const isDisabled = disabled || isDisabledState(state);
const isActive = state === 'active';
const subtitleText = getSubtitleText(state);
const subtitleColor = getSubtitleColor(state);
const textColor = isDisabled ? slate400 : black;
// Determine circle color based on state
const circleColor = isDisabled ? slate200 : slate300;
return (
<>
<Pressable
onPress={isDisabled ? undefined : onPress}
disabled={isDisabled}
testID={testID}
>
<XStack
paddingVertical={6}
paddingHorizontal={0}
alignItems="center"
gap={13}
opacity={isDisabled ? 0.6 : 1}
>
{/* Radio button indicator */}
<View
width={29}
height={24}
alignItems="center"
justifyContent="center"
>
<View
width={24}
height={24}
borderRadius={12}
borderWidth={isActive ? 0 : 2}
borderColor={circleColor}
backgroundColor={isActive ? green500 : 'transparent'}
alignItems="center"
justifyContent="center"
>
{isActive && <Check size={16} color="white" strokeWidth={3} />}
</View>
</View>
{/* Document info */}
<YStack flex={1} gap={2} paddingVertical={8} paddingBottom={9}>
<Text
fontFamily={dinot}
fontSize={18}
fontWeight="500"
color={textColor}
>
{documentName}
</Text>
<Text fontFamily={dinot} fontSize={14} color={subtitleColor}>
{subtitleText}
</Text>
</YStack>
</XStack>
</Pressable>
{!isLastItem && <Separator borderColor={iosSeparator} />}
</>
);
};
export function isDisabledState(state: IDSelectorState): boolean {
return state === 'expired';
}

View File

@@ -0,0 +1,174 @@
// 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 { Button, ScrollView, Sheet, Text, View, XStack, YStack } from 'tamagui';
import {
black,
blue600,
slate200,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
import type { IDSelectorState } from '@/components/documents/IDSelectorItem';
import {
IDSelectorItem,
isDisabledState,
} from '@/components/documents/IDSelectorItem';
export interface IDSelectorDocument {
id: string;
name: string;
state: IDSelectorState;
}
export interface IDSelectorSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
documents: IDSelectorDocument[];
selectedId?: string;
onSelect: (documentId: string) => void;
onDismiss: () => void;
onApprove: () => void;
testID?: string;
}
export const IDSelectorSheet: React.FC<IDSelectorSheetProps> = ({
open,
onOpenChange,
documents,
selectedId,
onSelect,
onDismiss,
onApprove,
testID = 'id-selector-sheet',
}) => {
const bottomPadding = useSafeBottomPadding(16);
// Check if the selected document is valid (not expired or unregistered)
const selectedDoc = documents.find(d => d.id === selectedId);
const canApprove = selectedDoc && !isDisabledState(selectedDoc.state);
return (
<Sheet
modal
open={open}
onOpenChange={onOpenChange}
snapPoints={[55]}
animation="medium"
dismissOnSnapToBottom
>
<Sheet.Overlay
backgroundColor="rgba(0, 0, 0, 0.5)"
animation="lazy"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
<Sheet.Frame
backgroundColor={white}
borderTopLeftRadius="$9"
borderTopRightRadius="$9"
testID={testID}
>
<YStack padding={20} paddingTop={30} flex={1}>
{/* Header */}
<Text
fontSize={20}
fontFamily={dinot}
fontWeight="500"
color={black}
marginBottom={32}
>
Select an ID
</Text>
{/* Document List Container with border radius */}
<View
flex={1}
backgroundColor={white}
borderRadius={10}
overflow="hidden"
marginBottom={32}
>
<ScrollView
flex={1}
showsVerticalScrollIndicator={false}
testID={`${testID}-list`}
>
{documents.map((doc, index) => {
const isSelected = doc.id === selectedId;
// Don't override to 'active' if the document is in a disabled state
const itemState: IDSelectorState =
isSelected && !isDisabledState(doc.state)
? 'active'
: doc.state;
return (
<IDSelectorItem
key={doc.id}
documentName={doc.name}
state={itemState}
onPress={() => onSelect(doc.id)}
isLastItem={index === documents.length - 1}
testID={`${testID}-item-${doc.id}`}
/>
);
})}
</ScrollView>
</View>
{/* Footer Buttons */}
<XStack gap={10} paddingBottom={bottomPadding}>
<Button
flex={1}
backgroundColor={white}
borderWidth={1}
borderColor={slate200}
borderRadius={4}
height={48}
alignItems="center"
justifyContent="center"
onPress={onDismiss}
testID={`${testID}-dismiss-button`}
pressStyle={{ opacity: 0.7 }}
>
<Text
fontFamily={dinot}
fontSize={18}
fontWeight="500"
color={black}
>
Dismiss
</Text>
</Button>
<Button
flex={1}
backgroundColor={blue600}
borderRadius={4}
height={48}
alignItems="center"
justifyContent="center"
onPress={onApprove}
disabled={!canApprove}
opacity={canApprove ? 1 : 0.5}
testID={`${testID}-select-button`}
pressStyle={{ opacity: 0.7 }}
>
<Text
fontFamily={dinot}
fontSize={18}
fontWeight="500"
color={white}
>
Approve
</Text>
</Button>
</XStack>
</YStack>
</Sheet.Frame>
</Sheet>
);
};

View File

@@ -0,0 +1,18 @@
// 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.
export type {
IDSelectorDocument,
IDSelectorSheetProps,
} from '@/components/documents/IDSelectorSheet';
export type {
IDSelectorItemProps,
IDSelectorState,
} from '@/components/documents/IDSelectorItem';
export {
IDSelectorItem,
isDisabledState,
} from '@/components/documents/IDSelectorItem';
export { IDSelectorSheet } from '@/components/documents/IDSelectorSheet';

View File

@@ -33,6 +33,7 @@ interface RightActionProps extends ViewProps {
interface NavBarTitleProps extends TextProps {
children?: React.ReactNode;
size?: 'large' | undefined;
color?: string;
}
export const LeftAction: React.FC<LeftActionProps> = ({
@@ -84,13 +85,20 @@ export const LeftAction: React.FC<LeftActionProps> = ({
return <View {...props}>{children}</View>;
};
const NavBarTitle: React.FC<NavBarTitleProps> = ({ children, ...props }) => {
const NavBarTitle: React.FC<NavBarTitleProps> = ({
children,
color,
style,
...props
}) => {
if (!children) {
return null;
}
return typeof children === 'string' ? (
<Title {...props}>{children}</Title>
<Title style={[color ? { color } : undefined, style]} {...props}>
{children}
</Title>
) : (
children
);

View File

@@ -18,6 +18,8 @@ export const DefaultNavBar = (props: NativeStackHeaderProps) => {
const { options } = props;
const headerStyle = (options.headerStyle || {}) as ViewStyle;
const insets = useSafeAreaInsets();
const headerTitleStyle = (options.headerTitleStyle || {}) as TextStyle;
return (
<NavBar.Container
gap={14}
@@ -26,8 +28,7 @@ export const DefaultNavBar = (props: NativeStackHeaderProps) => {
paddingBottom={20}
backgroundColor={headerStyle.backgroundColor as string}
barStyle={
options.headerTintColor === white ||
(options.headerTitleStyle as TextStyle)?.color === white
options.headerTintColor === white || headerTitleStyle?.color === white
? 'light'
: 'dark'
}
@@ -40,9 +41,12 @@ export const DefaultNavBar = (props: NativeStackHeaderProps) => {
buttonTap();
goBack();
}}
{...(options.headerTitleStyle as ViewStyle)}
color={options.headerTintColor as string}
/>
<NavBar.Title {...(options.headerTitleStyle as ViewStyle)}>
<NavBar.Title
color={headerTitleStyle.color as string}
style={headerTitleStyle}
>
{props.options.title}
</NavBar.Title>
</NavBar.Container>

View File

@@ -56,7 +56,7 @@ export const HomeNavBar = (props: NativeStackHeaderProps) => {
try {
Clipboard.setString('');
} catch {}
props.navigation.navigate('Prove');
props.navigation.navigate('ProvingScreenRouter');
} catch (error) {
console.error('Error consuming token:', error);
if (

View File

@@ -0,0 +1,170 @@
// 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, { useMemo } from 'react';
import {
ActivityIndicator,
Dimensions,
Pressable,
StyleSheet,
} from 'react-native';
import { Text, View, XStack } from 'tamagui';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
import { proofRequestColors } from '@/components/proof-request/designTokens';
import { ChevronUpDownIcon } from '@/components/proof-request/icons';
export interface BottomActionBarProps {
selectedDocumentName: string;
onDocumentSelectorPress: () => void;
onApprovePress: () => void;
approveDisabled?: boolean;
approving?: boolean;
testID?: string;
}
/**
* Bottom action bar with document selector and approve button.
* Matches Figma design 15234:9322.
*/
export const BottomActionBar: React.FC<BottomActionBarProps> = ({
selectedDocumentName,
onDocumentSelectorPress,
onApprovePress,
approveDisabled = false,
approving = false,
testID = 'bottom-action-bar',
}) => {
// Reduce top padding to balance with safe area bottom padding
// The safe area hook adds significant padding on small screens for system UI
const topPadding = 8;
// Calculate dynamic bottom padding based on screen height
// Scales proportionally to better center the select box beneath the disclosure list
const { height: screenHeight } = Dimensions.get('window');
const basePadding = 12;
// Get safe area padding (handles small screens < 900px with extra padding)
const safeAreaPadding = useSafeBottomPadding(basePadding);
// Dynamic padding calculation:
// - Start with safe area padding (includes base + small screen adjustment)
// - Add additional padding that scales with screen height
// - Formula: safeAreaPadding + (screenHeight - 800) * 0.12
// - This provides base padding, safe area handling, plus 0-50px extra on larger screens
// - The multiplier (0.12) ensures smooth scaling across different screen sizes
const dynamicPadding = useMemo(() => {
const heightMultiplier = Math.max(0, (screenHeight - 800) * 0.12);
return Math.round(safeAreaPadding + heightMultiplier);
}, [screenHeight, safeAreaPadding]);
const bottomPadding = dynamicPadding;
return (
<View
backgroundColor={proofRequestColors.white}
paddingHorizontal={16}
paddingTop={topPadding}
paddingBottom={bottomPadding}
testID={testID}
>
<XStack gap={12}>
{/* Document Selector Button */}
<Pressable
onPress={onDocumentSelectorPress}
style={({ pressed }) => [
styles.documentButton,
pressed && styles.documentButtonPressed,
]}
testID={`${testID}-document-selector`}
>
<XStack
alignItems="center"
justifyContent="space-between"
paddingHorizontal={12}
paddingVertical={12}
>
<Text
fontFamily={dinot}
fontSize={18}
color={proofRequestColors.slate900}
numberOfLines={1}
>
{selectedDocumentName}
</Text>
<View marginLeft={8}>
<ChevronUpDownIcon
size={20}
color={proofRequestColors.slate400}
/>
</View>
</XStack>
</Pressable>
{/* Approve Button */}
<Pressable
onPress={onApprovePress}
disabled={approveDisabled || approving}
style={({ pressed }) => [
styles.approveButton,
(approveDisabled || approving) && styles.approveButtonDisabled,
pressed &&
!approveDisabled &&
!approving &&
styles.approveButtonPressed,
]}
testID={`${testID}-approve`}
>
<View
alignItems="center"
justifyContent="center"
paddingHorizontal={12}
paddingVertical={12}
>
{approving ? (
<ActivityIndicator
color={proofRequestColors.white}
size="small"
/>
) : (
<Text
fontFamily={dinot}
fontSize={18}
color={proofRequestColors.white}
textAlign="center"
>
Approve
</Text>
)}
</View>
</Pressable>
</XStack>
</View>
);
};
const styles = StyleSheet.create({
documentButton: {
backgroundColor: proofRequestColors.white,
borderWidth: 1,
borderColor: proofRequestColors.slate200,
borderRadius: 4,
},
documentButtonPressed: {
backgroundColor: proofRequestColors.slate100,
},
approveButton: {
flex: 1,
backgroundColor: proofRequestColors.blue600,
borderRadius: 4,
},
approveButtonDisabled: {
opacity: 0.5,
},
approveButtonPressed: {
backgroundColor: proofRequestColors.blue700,
},
});

View File

@@ -0,0 +1,49 @@
// 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 { useSafeAreaInsets } from 'react-native-safe-area-context';
import { View } from 'tamagui';
import { HeldPrimaryButtonProveScreen } from '@selfxyz/mobile-sdk-alpha/components';
import { proofRequestColors } from '@/components/proof-request/designTokens';
export interface BottomVerifyBarProps {
onVerify: () => void;
selectedAppSessionId: string | undefined | null;
hasScrolledToBottom: boolean;
isReadyToProve: boolean;
isDocumentExpired: boolean;
testID?: string;
}
export const BottomVerifyBar: React.FC<BottomVerifyBarProps> = ({
onVerify,
selectedAppSessionId,
hasScrolledToBottom,
isReadyToProve,
isDocumentExpired,
testID = 'bottom-verify-bar',
}) => {
const insets = useSafeAreaInsets();
return (
<View
backgroundColor={proofRequestColors.white}
paddingHorizontal={16}
paddingTop={12}
paddingBottom={Math.max(insets.bottom, 12) + 20}
testID={testID}
>
<HeldPrimaryButtonProveScreen
onVerify={onVerify}
selectedAppSessionId={selectedAppSessionId}
hasScrolledToBottom={hasScrolledToBottom}
isReadyToProve={isReadyToProve}
isDocumentExpired={isDocumentExpired}
/>
</View>
);
};

View File

@@ -0,0 +1,103 @@
// 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 { Pressable } from 'react-native';
import { Text, View, XStack } from 'tamagui';
import { plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { proofRequestColors } from '@/components/proof-request/designTokens';
import { WalletIcon } from '@/components/proof-request/icons';
export interface ConnectedWalletBadgeProps {
address: string;
userIdType?: string;
onToggle?: () => void;
testID?: string;
}
/**
* Blue badge showing connected wallet address.
* Matches Figma design 15234:9295 (icon).
*/
export const ConnectedWalletBadge: React.FC<ConnectedWalletBadgeProps> = ({
address,
userIdType,
onToggle,
testID = 'connected-wallet-badge',
}) => {
const label = userIdType === 'hex' ? 'Connected Wallet' : 'Connected ID';
const content = (
<XStack
backgroundColor={proofRequestColors.blue600}
paddingLeft={10}
paddingRight={20}
paddingVertical={12}
borderRadius={4}
alignItems="center"
gap={10}
testID={testID}
>
{/* Label with icon */}
<XStack
backgroundColor={proofRequestColors.blue700}
paddingHorizontal={6}
paddingVertical={4}
borderRadius={3}
alignItems="center"
gap={6}
>
<WalletIcon size={11} color={proofRequestColors.white} />
<Text
fontFamily={plexMono}
fontSize={12}
color={proofRequestColors.white}
textTransform="uppercase"
>
{label}
</Text>
</XStack>
{/* Address */}
<View flex={1}>
<Text
fontFamily={plexMono}
fontSize={12}
color={proofRequestColors.white}
textAlign="right"
testID={`${testID}-address`}
>
{truncateAddress(address)}
</Text>
</View>
</XStack>
);
if (onToggle) {
return (
<Pressable onPress={onToggle} testID={`${testID}-pressable`}>
{content}
</Pressable>
);
}
return content;
};
/**
* Truncates a wallet address for display.
* @example truncateAddress("0x1234567890abcdef1234567890abcdef12345678") // "0x12..5678"
*/
export function truncateAddress(
address: string,
startChars = 4,
endChars = 4,
): string {
if (address.length <= startChars + endChars + 2) {
return address;
}
return `${address.slice(0, startChars)}..${address.slice(-endChars)}`;
}

View File

@@ -0,0 +1,85 @@
// 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 { Pressable } from 'react-native';
import { Text, View, XStack } from 'tamagui';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { proofRequestColors } from '@/components/proof-request/designTokens';
import {
FilledCircleIcon,
InfoCircleIcon,
} from '@/components/proof-request/icons';
export interface DisclosureItemProps {
text: string;
verified?: boolean;
onInfoPress?: () => void;
isLast?: boolean;
testID?: string;
}
/**
* Individual disclosure row with green checkmark and optional info button.
* Matches Figma design 15234:9267.
*/
export const DisclosureItem: React.FC<DisclosureItemProps> = ({
text,
verified = true,
onInfoPress,
isLast = false,
testID = 'disclosure-item',
}) => {
return (
<XStack
paddingVertical={16}
alignItems="center"
gap={10}
borderBottomWidth={isLast ? 0 : 1}
borderBottomColor={proofRequestColors.slate200}
testID={testID}
>
{/* Status Icon */}
<View width={20} alignItems="center" justifyContent="center">
<FilledCircleIcon
size={9}
color={
verified
? proofRequestColors.emerald500
: proofRequestColors.slate400
}
/>
</View>
{/* Disclosure Text */}
<View flex={1}>
<Text
fontFamily={dinot}
fontSize={12}
color={proofRequestColors.slate900}
textTransform="uppercase"
letterSpacing={0.48}
testID={`${testID}-text`}
>
{text}
</Text>
</View>
{/* Info Button */}
{onInfoPress && (
<Pressable
onPress={onInfoPress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
testID={`${testID}-info-button`}
>
<View width={25} alignItems="center" justifyContent="center">
<InfoCircleIcon size={20} color={proofRequestColors.blue500} />
</View>
</Pressable>
)}
</XStack>
);
};

View File

@@ -0,0 +1,89 @@
// 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 { Text, View, XStack } from 'tamagui';
import { plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { proofRequestColors } from '@/components/proof-request/designTokens';
import { DocumentIcon } from '@/components/proof-request/icons';
export interface ProofMetadataBarProps {
timestamp: string;
testID?: string;
}
/**
* Gray metadata bar showing "PROOFS REQUESTED" label and timestamp.
* Matches Figma design 15234:9281.
*/
export const ProofMetadataBar: React.FC<ProofMetadataBarProps> = ({
timestamp,
testID = 'proof-metadata-bar',
}) => {
return (
<View
backgroundColor={proofRequestColors.slate200}
paddingVertical={6}
borderBottomWidth={1}
borderBottomColor={proofRequestColors.slate200}
testID={testID}
>
<XStack gap={10} alignItems="center" justifyContent="center" width="100%">
{/* Icon + Label group */}
<XStack gap={6} alignItems="center">
<DocumentIcon size={14} color={proofRequestColors.slate400} />
<Text
fontFamily={plexMono}
fontSize={12}
fontWeight="500"
color={proofRequestColors.slate400}
textTransform="uppercase"
>
Proofs Requested
</Text>
</XStack>
{/* Dot separator */}
<Text
fontFamily={plexMono}
fontSize={12}
fontWeight="500"
color={proofRequestColors.slate400}
>
</Text>
{/* Timestamp */}
<Text
fontFamily={plexMono}
fontSize={12}
fontWeight="500"
color={proofRequestColors.slate400}
testID={`${testID}-timestamp`}
>
{timestamp}
</Text>
</XStack>
</View>
);
};
/**
* Formats a Date object to match the Figma timestamp format.
* @example formatTimestamp(new Date()) // "4/7/2025 11:44 AM"
*/
export function formatTimestamp(date: Date): string {
const month = date.getMonth() + 1;
const day = date.getDate();
const year = date.getFullYear();
const hours = date.getHours();
const minutes = date.getMinutes();
const ampm = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours % 12 || 12;
const displayMinutes = minutes.toString().padStart(2, '0');
return `${month}/${day}/${year} ${displayHours}:${displayMinutes} ${ampm}`;
}

View File

@@ -0,0 +1,138 @@
// 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, { useMemo } from 'react';
import type {
ImageSourcePropType,
LayoutChangeEvent,
NativeScrollEvent,
NativeSyntheticEvent,
ScrollView as ScrollViewType,
} from 'react-native';
import { ScrollView } from 'react-native';
import { Text, View } from 'tamagui';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import {
proofRequestColors,
proofRequestSpacing,
} from '@/components/proof-request/designTokens';
import {
formatTimestamp,
ProofMetadataBar,
} from '@/components/proof-request/ProofMetadataBar';
import { ProofRequestHeader } from '@/components/proof-request/ProofRequestHeader';
export interface ProofRequestCardProps {
logoSource: ImageSourcePropType | null;
appName: string;
appUrl: string | null;
documentType?: string;
timestamp?: Date;
children?: React.ReactNode;
testID?: string;
onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
scrollViewRef?: React.RefObject<ScrollViewType>;
onContentSizeChange?: (width: number, height: number) => void;
onLayout?: (event: LayoutChangeEvent) => void;
initialScrollOffset?: number;
}
/**
* Main card container for proof request screens.
* Combines header, metadata bar, and content section.
* Matches Figma design 15234:9267.
*/
export const ProofRequestCard: React.FC<ProofRequestCardProps> = ({
logoSource,
appName,
appUrl,
documentType = '',
timestamp,
children,
testID = 'proof-request-card',
onScroll,
scrollViewRef,
onContentSizeChange,
onLayout,
initialScrollOffset,
}) => {
// Create default timestamp once and reuse it to avoid unnecessary re-renders
const defaultTimestamp = useMemo(() => new Date(), []);
const effectiveTimestamp = timestamp ?? defaultTimestamp;
// Build request message with highlighted app name and document type
const requestMessage = (
<>
<Text color={proofRequestColors.white} fontFamily={dinot}>
{appName}
</Text>
<Text color={proofRequestColors.slate400} fontFamily={dinot}>
{
' is requesting access to the following information from your verified '
}
</Text>
<Text color={proofRequestColors.white} fontFamily={dinot}>
{documentType}
</Text>
<Text color={proofRequestColors.slate400} fontFamily={dinot}>
.
</Text>
</>
);
return (
<View flex={1} paddingVertical={20} paddingHorizontal={20} testID={testID}>
<View
borderRadius={proofRequestSpacing.borderRadius}
borderWidth={1}
borderColor={proofRequestColors.slate200}
overflow="hidden"
flex={1}
>
{/* Black Header */}
<ProofRequestHeader
logoSource={logoSource}
appName={appName}
appUrl={appUrl}
requestMessage={requestMessage}
testID={`${testID}-header`}
/>
{/* Metadata Bar */}
<ProofMetadataBar
timestamp={formatTimestamp(effectiveTimestamp)}
testID={`${testID}-metadata`}
/>
{/* White Content Area */}
<View
flex={1}
backgroundColor={proofRequestColors.slate100}
padding={proofRequestSpacing.cardPadding}
borderBottomLeftRadius={proofRequestSpacing.borderRadius}
borderBottomRightRadius={proofRequestSpacing.borderRadius}
>
<ScrollView
ref={scrollViewRef}
showsVerticalScrollIndicator={true}
contentContainerStyle={{ flexGrow: 1 }}
onScroll={onScroll}
scrollEventThrottle={16}
onContentSizeChange={onContentSizeChange}
onLayout={onLayout}
contentOffset={
typeof initialScrollOffset === 'number'
? { x: 0, y: initialScrollOffset }
: undefined
}
>
{children}
</ScrollView>
</View>
</View>
</View>
);
};

View File

@@ -0,0 +1,104 @@
// 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 type { ImageSourcePropType } from 'react-native';
import { Image, Text, View, YStack } from 'tamagui';
import {
advercase,
dinot,
plexMono,
} from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { proofRequestColors } from '@/components/proof-request/designTokens';
export interface ProofRequestHeaderProps {
logoSource: ImageSourcePropType | null;
appName: string;
appUrl: string | null;
requestMessage: React.ReactNode;
testID?: string;
}
/**
* Black header section for proof request screens.
* Displays app logo, name, URL, and request description.
* Matches Figma design 15234:9267.
*/
export const ProofRequestHeader: React.FC<ProofRequestHeaderProps> = ({
logoSource,
appName,
appUrl,
requestMessage,
testID = 'proof-request-header',
}) => {
const hasLogo = logoSource !== null;
return (
<View
backgroundColor={proofRequestColors.black}
padding={30}
gap={20}
testID={testID}
>
{/* Logo and App Info Row */}
<View flexDirection="row" alignItems="center" gap={20}>
{logoSource && (
<View
width={50}
height={50}
borderRadius={3}
overflow="hidden"
testID={`${testID}-logo`}
>
<Image
source={logoSource}
width={50}
height={50}
objectFit="contain"
/>
</View>
)}
<YStack>
<Text
fontFamily={advercase}
fontSize={28}
color={proofRequestColors.white}
letterSpacing={1}
testID={`${testID}-app-name`}
>
{appName}
</Text>
{appUrl && (
<View marginRight={hasLogo ? 50 : 0}>
<Text
fontFamily={plexMono}
fontSize={12}
color={proofRequestColors.zinc500}
testID={`${testID}-app-url`}
numberOfLines={1}
ellipsizeMode="middle"
>
{appUrl}
</Text>
</View>
)}
</YStack>
</View>
{/* Request Description */}
<Text
fontFamily={dinot}
fontSize={16}
color={proofRequestColors.slate400}
lineHeight={24}
minHeight={75}
testID={`${testID}-request-message`}
>
{requestMessage}
</Text>
</View>
);
};

View File

@@ -0,0 +1,240 @@
// 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, { useCallback, useEffect, useRef, useState } from 'react';
import { Modal, Pressable, StyleSheet } from 'react-native';
import { Text, View, XStack, YStack } from 'tamagui';
import Clipboard from '@react-native-clipboard/clipboard';
import { dinot, plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { proofRequestColors } from '@/components/proof-request/designTokens';
import { CopyIcon, WalletIcon } from '@/components/proof-request/icons';
export interface WalletAddressModalProps {
visible: boolean;
onClose: () => void;
address: string;
userIdType?: string;
testID?: string;
}
/**
* Modal that displays the full wallet address with copy functionality.
* Appears when user taps on the truncated wallet badge.
*/
export const WalletAddressModal: React.FC<WalletAddressModalProps> = ({
visible,
onClose,
address,
userIdType,
testID = 'wallet-address-modal',
}) => {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const label = userIdType === 'hex' ? 'Connected Wallet' : 'Connected ID';
// Reset copied state when modal closes
useEffect(() => {
if (!visible) {
setCopied(false);
}
}, [visible]);
// Clear timeout on unmount or when modal closes/address changes
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [visible, address, onClose]);
const handleCopy = useCallback(() => {
// Clear any existing timeout before setting a new one
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
Clipboard.setString(address);
setCopied(true);
// Reset copied state and close after a brief delay
timeoutRef.current = setTimeout(() => {
setCopied(false);
onClose();
timeoutRef.current = null;
}, 800);
}, [address, onClose]);
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
testID={testID}
>
<Pressable style={styles.backdrop} onPress={onClose}>
<View style={styles.container}>
<Pressable onPress={e => e.stopPropagation()}>
<YStack
backgroundColor={proofRequestColors.white}
borderRadius={16}
paddingHorizontal={24}
paddingVertical={24}
gap={20}
minWidth={300}
maxWidth="90%"
elevation={8}
shadowColor={proofRequestColors.black}
shadowOffset={{ width: 0, height: 4 }}
shadowOpacity={0.3}
shadowRadius={8}
>
{/* Header */}
<YStack gap={8}>
<XStack alignItems="center" gap={8}>
<WalletIcon size={20} color={proofRequestColors.blue600} />
<Text
fontFamily={dinot}
fontSize={18}
color={proofRequestColors.slate900}
fontWeight="600"
>
{label}
</Text>
</XStack>
</YStack>
{/* Full Address */}
<View
backgroundColor={proofRequestColors.slate100}
padding={16}
borderRadius={8}
borderWidth={1}
borderColor={proofRequestColors.slate200}
>
<Text
fontFamily={plexMono}
fontSize={14}
color={proofRequestColors.slate900}
numberOfLines={undefined}
ellipsizeMode="middle"
testID={`${testID}-full-address`}
>
{address}
</Text>
</View>
{/* Action Buttons */}
<XStack gap={12}>
<Pressable
onPress={handleCopy}
disabled={copied}
style={({ pressed }) => [
copied ? styles.copiedButton : styles.copyButton,
pressed && !copied && styles.copyButtonPressed,
]}
testID={`${testID}-copy`}
>
<XStack
alignItems="center"
justifyContent="center"
gap={8}
paddingVertical={14}
>
{copied ? (
<Text
fontSize={16}
fontWeight="600"
color={proofRequestColors.white}
>
</Text>
) : (
<CopyIcon size={16} color={proofRequestColors.white} />
)}
<Text
fontFamily={dinot}
fontSize={16}
color={proofRequestColors.white}
fontWeight="600"
>
{copied ? 'Copied!' : 'Copy'}
</Text>
</XStack>
</Pressable>
{!copied && (
<Pressable
onPress={onClose}
style={({ pressed }) => [
styles.closeButton,
pressed && styles.closeButtonPressed,
]}
testID={`${testID}-close`}
>
<View
alignItems="center"
justifyContent="center"
paddingVertical={14}
>
<Text
fontFamily={dinot}
fontSize={16}
color={proofRequestColors.slate500}
fontWeight="600"
>
Close
</Text>
</View>
</Pressable>
)}
</XStack>
</YStack>
</Pressable>
</View>
</Pressable>
</Modal>
);
};
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
copyButton: {
flex: 1,
backgroundColor: proofRequestColors.blue600,
borderRadius: 8,
},
copyButtonPressed: {
backgroundColor: proofRequestColors.blue700,
},
copiedButton: {
flex: 1,
backgroundColor: proofRequestColors.emerald500,
borderRadius: 8,
},
closeButton: {
flex: 1,
backgroundColor: proofRequestColors.slate100,
borderRadius: 8,
borderWidth: 1,
borderColor: proofRequestColors.slate200,
},
closeButtonPressed: {
backgroundColor: proofRequestColors.slate200,
},
});

View File

@@ -0,0 +1,40 @@
// 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.
/**
* Design tokens for proof request components.
* Extracted from Figma design 15234:9267 and 15234:9322.
*/
export const proofRequestColors = {
// Base colors
black: '#000000',
white: '#FFFFFF',
// Slate palette
slate100: '#F8FAFC',
slate200: '#E2E8F0',
slate400: '#94A3B8',
slate500: '#71717A',
slate900: '#0F172A',
// Blue palette
blue500: '#3B82F6',
blue600: '#2563EB',
blue700: '#1D4ED8',
// Status colors
emerald500: '#10B981',
// Zinc palette
zinc500: '#71717A',
} as const;
export const proofRequestSpacing = {
cardPadding: 20,
headerPadding: 30,
itemPadding: 16,
borderRadius: 10,
borderRadiusSmall: 4,
} as const;

View File

@@ -0,0 +1,143 @@
// 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 Svg, { Circle, Path, Rect } from 'react-native-svg';
export interface IconProps {
size?: number;
color?: string;
}
/**
* Chevron up/down icon (dropdown)
*/
export const ChevronUpDownIcon: React.FC<IconProps> = ({
size = 20,
color = '#94A3B8',
}) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path
d="M8 10L12 6L16 10M16 14L12 18L8 14"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
/**
* Copy icon
*/
export const CopyIcon: React.FC<IconProps> = ({
size = 16,
color = '#FFFFFF',
}) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Rect
x="9"
y="9"
width="13"
height="13"
rx="2"
stroke={color}
strokeWidth="2"
fill="none"
/>
<Path
d="M5 15H4C3.46957 15 2.96086 14.7893 2.58579 14.4142C2.21071 14.0391 2 13.5304 2 13V4C2 3.46957 2.21071 2.96086 2.58579 2.58579C2.96086 2.21071 3.46957 2 4 2H13C13.5304 2 14.0391 2.21071 14.4142 2.58579C14.7893 2.96086 15 3.46957 15 4V5"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
/**
* Document icon (lighter stroke to match SF Symbol design)
*/
export const DocumentIcon: React.FC<IconProps> = ({
size = 18,
color = '#94A3B8',
}) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path
d="M14 2H6C5.46957 2 4.96086 2.21071 4.58579 2.58579C4.21071 2.96086 4 3.46957 4 4V20C4 20.5304 4.21071 21.0391 4.58579 21.4142C4.96086 21.7893 5.46957 22 6 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V8L14 2Z"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<Path
d="M14 2V8H20"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<Path
d="M16 13H8M16 17H8M10 9H8"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
/**
* Filled circle icon (checkmark/bullet point)
*/
export const FilledCircleIcon: React.FC<IconProps> = ({
size = 18,
color = '#10B981',
}) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Circle cx="12" cy="12" r="10" fill={color} />
</Svg>
);
/**
* Info circle icon
*/
export const InfoCircleIcon: React.FC<IconProps> = ({
size = 20,
color = '#3B82F6',
}) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Circle cx="12" cy="12" r="10" stroke={color} strokeWidth="2" fill="none" />
<Path
d="M12 16V12M12 8H12.01"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
/**
* Wallet icon (credit card style to match SF Symbol creditcard 􀟿)
*/
export const WalletIcon: React.FC<IconProps> = ({
size = 16,
color = '#FFFFFF',
}) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Rect
x="2"
y="5"
width="20"
height="14"
rx="2"
stroke={color}
strokeWidth="2"
fill="none"
/>
<Path d="M2 10H22" stroke={color} strokeWidth="2" strokeLinecap="round" />
</Svg>
);

View File

@@ -0,0 +1,68 @@
// 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.
export type { BottomActionBarProps } from '@/components/proof-request/BottomActionBar';
export type { BottomVerifyBarProps } from '@/components/proof-request/BottomVerifyBar';
// Metadata bar
export type { ConnectedWalletBadgeProps } from '@/components/proof-request/ConnectedWalletBadge';
export type { DisclosureItemProps } from '@/components/proof-request/DisclosureItem';
export type { IconProps } from '@/components/proof-request/icons';
// Header section
export type { ProofMetadataBarProps } from '@/components/proof-request/ProofMetadataBar';
/**
* Proof Request Component Library
*
* Shared components for proof request preview and proving screens.
* These components implement the Figma designs 15234:9267 and 15234:9322.
*/
// Main card component
export type { ProofRequestCardProps } from '@/components/proof-request/ProofRequestCard';
export type { ProofRequestHeaderProps } from '@/components/proof-request/ProofRequestHeader';
export type { WalletAddressModalProps } from '@/components/proof-request/WalletAddressModal';
// Icons
export { BottomActionBar } from '@/components/proof-request/BottomActionBar';
export { BottomVerifyBar } from '@/components/proof-request/BottomVerifyBar';
// Bottom action bar
export {
ChevronUpDownIcon,
CopyIcon,
DocumentIcon,
FilledCircleIcon,
InfoCircleIcon,
WalletIcon,
} from '@/components/proof-request/icons';
export {
ConnectedWalletBadge,
truncateAddress,
} from '@/components/proof-request/ConnectedWalletBadge';
// Connected wallet badge
export { DisclosureItem } from '@/components/proof-request/DisclosureItem';
// Disclosure item
export {
ProofMetadataBar,
formatTimestamp,
} from '@/components/proof-request/ProofMetadataBar';
export { ProofRequestCard } from '@/components/proof-request/ProofRequestCard';
export { ProofRequestHeader } from '@/components/proof-request/ProofRequestHeader';
export { WalletAddressModal } from '@/components/proof-request/WalletAddressModal';
// Design tokens
export {
proofRequestColors,
proofRequestSpacing,
} from '@/components/proof-request/designTokens';

View File

@@ -40,7 +40,7 @@ export const useEarnPointsFlow = ({
// Use setTimeout to ensure modal dismisses before navigating
setTimeout(() => {
navigation.navigate('Prove');
navigation.navigate('ProvingScreenRouter');
}, 100);
}, [selfClient, navigation]);

View File

@@ -0,0 +1,62 @@
// 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 { useMemo } from 'react';
import type { SelfApp } from '@selfxyz/common';
import type { SelfAppDisclosureConfig } from '@selfxyz/common/utils/appType';
import { formatEndpoint } from '@selfxyz/common/utils/scope';
import { getDisclosureItems } from '@/utils/disclosureUtils';
import { formatUserId } from '@/utils/formatUserId';
/**
* Hook that extracts and transforms SelfApp data for use in UI components.
* Returns memoized values for logo source, URL, formatted user ID, and disclosure items.
*/
export function useSelfAppData(selfApp: SelfApp | null) {
const logoSource = useMemo(() => {
if (!selfApp?.logoBase64) {
return null;
}
// Check if the logo is already a URL
if (
selfApp.logoBase64.startsWith('http://') ||
selfApp.logoBase64.startsWith('https://')
) {
return { uri: selfApp.logoBase64 };
}
// Otherwise handle as base64
const base64String = selfApp.logoBase64.startsWith('data:image')
? selfApp.logoBase64
: `data:image/png;base64,${selfApp.logoBase64}`;
return { uri: base64String };
}, [selfApp?.logoBase64]);
const url = useMemo(() => {
if (!selfApp?.endpoint) {
return null;
}
return formatEndpoint(selfApp.endpoint);
}, [selfApp?.endpoint]);
const formattedUserId = useMemo(
() => formatUserId(selfApp?.userId, selfApp?.userIdType),
[selfApp?.userId, selfApp?.userIdType],
);
const disclosureItems = useMemo(() => {
const disclosures = (selfApp?.disclosures as SelfAppDisclosureConfig) || {};
return getDisclosureItems(disclosures);
}, [selfApp?.disclosures]);
return {
logoSource,
url,
formattedUserId,
disclosureItems,
};
}

View File

@@ -0,0 +1,40 @@
// 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 { useCallback } from 'react';
import { useFocusEffect } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { SelfApp } from '@selfxyz/common';
import type { RootStackParamList } from '@/navigation';
/**
* Hook that checks if SelfApp data is stale (missing or empty disclosures)
* and navigates to Home screen if stale data is detected.
*
* Uses a small delay to allow store updates to propagate after navigation
* (e.g., after QR code scan sets selfApp data).
*/
export function useSelfAppStalenessCheck(
selfApp: SelfApp | null,
disclosureItems: Array<{ key: string; text: string }>,
navigation: NativeStackNavigationProp<RootStackParamList>,
) {
useFocusEffect(
useCallback(() => {
// Add a small delay to allow Zustand store updates to propagate
// after navigation (e.g., when selfApp is set from QR scan)
const timeoutId = setTimeout(() => {
if (!selfApp || disclosureItems.length === 0) {
navigation.navigate({ name: 'Home', params: {} });
}
}, 300);
return () => {
clearTimeout(timeoutId);
};
}, [selfApp, disclosureItems.length, navigation]),
);
}

View File

@@ -16,6 +16,7 @@ import AccountRecoveryScreen from '@/screens/account/recovery/AccountRecoveryScr
import DocumentDataNotFoundScreen from '@/screens/account/recovery/DocumentDataNotFoundScreen';
import RecoverWithPhraseScreen from '@/screens/account/recovery/RecoverWithPhraseScreen';
import CloudBackupScreen from '@/screens/account/settings/CloudBackupScreen';
import { ProofSettingsScreen } from '@/screens/account/settings/ProofSettingsScreen';
import SettingsScreen from '@/screens/account/settings/SettingsScreen';
import ShowRecoveryPhraseScreen from '@/screens/account/settings/ShowRecoveryPhraseScreen';
import { IS_EUCLID_ENABLED } from '@/utils/devUtils';
@@ -65,6 +66,18 @@ const accountScreens = {
},
} as NativeStackNavigationOptions,
},
ProofSettings: {
screen: ProofSettingsScreen,
options: {
title: 'Proof Settings',
headerStyle: {
backgroundColor: white,
},
headerTitleStyle: {
color: black,
},
} as NativeStackNavigationOptions,
},
Settings: {
screen: SettingsScreen,
options: {

View File

@@ -126,7 +126,10 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => {
selfClient.getSelfAppState().startAppListener(selfAppJson.sessionId);
navigationRef.reset(
createDeeplinkNavigationState('Prove', correctParentScreen),
createDeeplinkNavigationState(
'ProvingScreenRouter',
correctParentScreen,
),
);
return;
@@ -143,7 +146,7 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => {
selfClient.getSelfAppState().startAppListener(sessionId);
navigationRef.reset(
createDeeplinkNavigationState('Prove', correctParentScreen),
createDeeplinkNavigationState('ProvingScreenRouter', correctParentScreen),
);
} else if (mock_passport) {
try {

View File

@@ -73,6 +73,8 @@ export type RootStackParamList = Omit<
| 'Disclaimer'
| 'DocumentNFCScan'
| 'DocumentOnboarding'
| 'DocumentSelectorForProving'
| 'ProvingScreenRouter'
| 'Gratification'
| 'Home'
| 'IDPicker'
@@ -142,13 +144,24 @@ export type RootStackParamList = Omit<
returnToScreen?: 'Points';
}
| undefined;
ProofSettings: undefined;
AccountVerifiedSuccess: undefined;
// Proof/Verification screens
ProofHistoryDetail: {
data: ProofHistory;
};
Prove: undefined;
Prove:
| {
scrollOffset?: number;
}
| undefined;
ProvingScreenRouter: undefined;
DocumentSelectorForProving:
| {
documentType?: string;
}
| undefined;
// App screens
Loading: {

View File

@@ -6,11 +6,30 @@ import type { NativeStackNavigationOptions } from '@react-navigation/native-stac
import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { DocumentSelectorForProvingScreen } from '@/screens/verification/DocumentSelectorForProvingScreen';
import ProofRequestStatusScreen from '@/screens/verification/ProofRequestStatusScreen';
import ProveScreen from '@/screens/verification/ProveScreen';
import { ProvingScreenRouter } from '@/screens/verification/ProvingScreenRouter';
import QRCodeTroubleScreen from '@/screens/verification/QRCodeTroubleScreen';
import QRCodeViewFinderScreen from '@/screens/verification/QRCodeViewFinderScreen';
/**
* Shared header configuration for proof request screens
*/
const proofRequestHeaderOptions: NativeStackNavigationOptions = {
title: 'Proof Requested',
headerStyle: {
backgroundColor: black,
},
headerTitleStyle: {
color: white,
fontWeight: '600',
},
headerTintColor: white,
gestureEnabled: false,
animation: 'none',
};
const verificationScreens = {
ProofRequestStatus: {
screen: ProofRequestStatusScreen,
@@ -20,18 +39,17 @@ const verificationScreens = {
gestureEnabled: false,
} as NativeStackNavigationOptions,
},
ProvingScreenRouter: {
screen: ProvingScreenRouter,
options: proofRequestHeaderOptions,
},
DocumentSelectorForProving: {
screen: DocumentSelectorForProvingScreen,
options: proofRequestHeaderOptions,
},
Prove: {
screen: ProveScreen,
options: {
title: 'Request Proof',
headerStyle: {
backgroundColor: black,
},
headerTitleStyle: {
color: white,
},
gestureEnabled: false,
} as NativeStackNavigationOptions,
options: proofRequestHeaderOptions,
},
QRCodeTrouble: {
screen: QRCodeTroubleScreen,

View File

@@ -0,0 +1,130 @@
// 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, Switch, Text, View } from 'react-native';
import { ScrollView, YStack } from 'tamagui';
import {
black,
blue600,
slate200,
slate500,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { useSettingStore } from '@/stores/settingStore';
const ProofSettingsScreen: React.FC = () => {
const {
skipDocumentSelector,
setSkipDocumentSelector,
skipDocumentSelectorIfSingle,
setSkipDocumentSelectorIfSingle,
} = useSettingStore();
return (
<YStack flex={1} backgroundColor={white}>
<ScrollView>
<YStack padding={20} gap={20}>
<Text style={styles.sectionTitle}>Document Selection</Text>
<View style={styles.settingRow}>
<View style={styles.settingTextContainer}>
<Text style={styles.settingLabel}>
Always skip document selection
</Text>
<Text style={styles.settingDescription}>
Go directly to proof generation using your previously selected
or first available document
</Text>
</View>
<Switch
value={skipDocumentSelector}
onValueChange={setSkipDocumentSelector}
trackColor={{ false: slate200, true: blue600 }}
thumbColor={white}
testID="skip-document-selector-toggle"
/>
</View>
<View style={styles.divider} />
<View style={styles.settingRow}>
<View style={styles.settingTextContainer}>
<Text style={styles.settingLabel}>
Skip when only one document
</Text>
<Text style={styles.settingDescription}>
Automatically select your document when you only have one valid
ID available
</Text>
</View>
<Switch
value={skipDocumentSelectorIfSingle}
onValueChange={setSkipDocumentSelectorIfSingle}
trackColor={{ false: slate200, true: blue600 }}
thumbColor={white}
disabled={skipDocumentSelector}
testID="skip-document-selector-if-single-toggle"
/>
</View>
{skipDocumentSelector && (
<Text style={styles.infoText}>
Document selection is always skipped. The &quot;Skip when only one
document&quot; setting has no effect.
</Text>
)}
</YStack>
</ScrollView>
</YStack>
);
};
const styles = StyleSheet.create({
sectionTitle: {
fontSize: 14,
fontFamily: dinot,
fontWeight: '600',
color: slate500,
textTransform: 'uppercase',
letterSpacing: 1,
},
settingRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
},
settingTextContainer: {
flex: 1,
gap: 4,
},
settingLabel: {
fontSize: 16,
fontFamily: dinot,
fontWeight: '500',
color: black,
},
settingDescription: {
fontSize: 14,
fontFamily: dinot,
color: slate500,
},
divider: {
height: 1,
backgroundColor: slate200,
},
infoText: {
fontSize: 13,
fontFamily: dinot,
fontStyle: 'italic',
color: slate500,
paddingHorizontal: 4,
},
});
export { ProofSettingsScreen };

View File

@@ -12,7 +12,7 @@ import type { SvgProps } from 'react-native-svg';
import { Button, ScrollView, View, XStack, YStack } from 'tamagui';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Bug, FileText } from '@tamagui/lucide-icons';
import { Bug, FileText, Settings2 } from '@tamagui/lucide-icons';
import { BodyText, pressedStyle } from '@selfxyz/mobile-sdk-alpha/components';
import {
@@ -78,6 +78,7 @@ const routes =
[Data, 'View document info', 'DocumentDataInfo'],
[Lock, 'Reveal recovery phrase', 'ShowRecoveryPhrase'],
[Cloud, 'Cloud backup', 'CloudBackupSettings'],
[Settings2 as React.FC<SvgProps>, 'Proof settings', 'ProofSettings'],
[Feedback, 'Send feedback', 'email_feedback'],
[ShareIcon, 'Share Self app', 'share'],
[
@@ -88,6 +89,7 @@ const routes =
] satisfies [React.FC<SvgProps>, string, RouteOption][])
: ([
[Data, 'View document info', 'DocumentDataInfo'],
[Settings2 as React.FC<SvgProps>, 'Proof settings', 'ProofSettings'],
[Feedback, 'Send feeback', 'email_feedback'],
[
FileText as React.FC<SvgProps>,

View File

@@ -0,0 +1,495 @@
// 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, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
import { ActivityIndicator, StyleSheet } from 'react-native';
import { Text, View, YStack } from 'tamagui';
import type { RouteProp } from '@react-navigation/native';
import {
useFocusEffect,
useNavigation,
useRoute,
} from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type {
DocumentCatalog,
DocumentMetadata,
IDDocument,
} from '@selfxyz/common/utils/types';
import {
getDocumentAttributes,
isDocumentValidForProving,
useSelfClient,
} from '@selfxyz/mobile-sdk-alpha';
import { blue600, white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import type { IDSelectorState } from '@/components/documents';
import { IDSelectorSheet, isDisabledState } from '@/components/documents';
import {
BottomActionBar,
ConnectedWalletBadge,
DisclosureItem,
ProofRequestCard,
proofRequestColors,
truncateAddress,
WalletAddressModal,
} from '@/components/proof-request';
import { useSelfAppData } from '@/hooks/useSelfAppData';
import { useSelfAppStalenessCheck } from '@/hooks/useSelfAppStalenessCheck';
import type { RootStackParamList } from '@/navigation';
import { usePassport } from '@/providers/passportDataProvider';
import { getDocumentTypeName } from '@/utils/documentUtils';
function getDocumentDisplayName(
metadata: DocumentMetadata,
documentData?: IDDocument,
): string {
const category = metadata.documentCategory || '';
const isMock = metadata.mock;
// Extract country information from document data
let countryCode: string | null = null;
if (documentData) {
try {
const attributes = getDocumentAttributes(documentData);
countryCode = attributes.nationalitySlice || null;
} catch {
// If we can't extract attributes, continue without country
}
}
const mockPrefix = isMock ? 'Dev ' : '';
if (category === 'passport') {
const base = 'Passport';
return countryCode
? `${mockPrefix}${countryCode} ${base}`
: `${mockPrefix}${base}`;
} else if (category === 'id_card') {
const base = 'ID Card';
return countryCode
? `${mockPrefix}${countryCode} ${base}`
: `${mockPrefix}${base}`;
} else if (category === 'aadhaar') {
return isMock ? 'Dev Aadhaar ID' : 'Aadhaar ID';
}
return isMock ? `Dev ${metadata.documentType}` : metadata.documentType;
}
function determineDocumentState(
metadata: DocumentMetadata,
documentData: IDDocument | undefined,
): IDSelectorState {
// Use SDK to check if document is valid (not expired)
if (!isDocumentValidForProving(metadata, documentData)) {
return 'expired';
}
// UI-specific state mapping: Mock documents are selectable but marked as developer/mock
if (metadata.mock) {
return 'mock';
}
// Both registered and non-registered real documents are valid for selection
// They will be registered during the proving flow if needed
return 'verified';
}
const DocumentSelectorForProvingScreen: React.FC = () => {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route =
useRoute<RouteProp<RootStackParamList, 'DocumentSelectorForProving'>>();
const selfClient = useSelfClient();
const { useSelfAppStore } = selfClient;
const selfApp = useSelfAppStore(state => state.selfApp);
const { loadDocumentCatalog, getAllDocuments, setSelectedDocument } =
usePassport();
// Extract SelfApp data using hook
const { logoSource, url, formattedUserId, disclosureItems } =
useSelfAppData(selfApp);
// Check for stale data and navigate to Home if needed
useSelfAppStalenessCheck(selfApp, disclosureItems, navigation);
const [documentCatalog, setDocumentCatalog] = useState<DocumentCatalog>({
documents: [],
});
const [allDocuments, setAllDocuments] = useState<
Record<string, { data: IDDocument; metadata: DocumentMetadata }>
>({});
const [selectedDocumentId, setSelectedDocumentId] = useState<
string | undefined
>();
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const [walletModalOpen, setWalletModalOpen] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const scrollOffsetRef = useRef(0);
const pickInitialDocument = useCallback(
(
catalog: DocumentCatalog,
docs: Record<string, { data: IDDocument; metadata: DocumentMetadata }>,
) => {
if (catalog.selectedDocumentId) {
const selectedMeta = catalog.documents.find(
doc => doc.id === catalog.selectedDocumentId,
);
const selectedData = selectedMeta
? docs[catalog.selectedDocumentId]
: undefined;
if (selectedMeta && selectedData) {
const state = determineDocumentState(selectedMeta, selectedData.data);
if (!isDisabledState(state)) {
return catalog.selectedDocumentId;
}
} else if (selectedMeta) {
return catalog.selectedDocumentId;
}
}
const firstValid = catalog.documents.find(doc => {
const docData = docs[doc.id];
const state = determineDocumentState(doc, docData?.data);
return !isDisabledState(state);
});
return firstValid?.id;
},
[],
);
const loadDocuments = useCallback(async () => {
// Cancel any in-flight request
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setLoading(true);
setError(null);
try {
const catalog = await loadDocumentCatalog();
const docs = await getAllDocuments();
// Don't update state if this request was aborted
if (controller.signal.aborted) {
return;
}
setDocumentCatalog(catalog);
setAllDocuments(docs);
setSelectedDocumentId(pickInitialDocument(catalog, docs));
} catch (loadError) {
// Don't show error if this request was aborted
if (controller.signal.aborted) {
return;
}
console.warn('Failed to load documents:', loadError);
setError('Unable to load documents.');
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}, [getAllDocuments, loadDocumentCatalog, pickInitialDocument]);
useFocusEffect(
useCallback(() => {
loadDocuments();
}, [loadDocuments]),
);
// Cleanup abort controller on unmount
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);
const documents = useMemo(() => {
return documentCatalog.documents
.map(metadata => {
const docData = allDocuments[metadata.id];
const baseState = determineDocumentState(metadata, docData?.data);
const isSelected = metadata.id === selectedDocumentId;
const itemState =
isSelected && !isDisabledState(baseState) ? 'active' : baseState;
return {
id: metadata.id,
name: getDocumentDisplayName(metadata, docData?.data),
state: itemState,
};
})
.sort((a, b) => {
// Get metadata for both documents
const metaA = documentCatalog.documents.find(d => d.id === a.id);
const metaB = documentCatalog.documents.find(d => d.id === b.id);
// Sort real documents before mock documents
if (metaA && metaB) {
if (metaA.mock !== metaB.mock) {
return metaA.mock ? 1 : -1; // Real first
}
}
// Within same type (real/mock), sort alphabetically by name
return a.name.localeCompare(b.name);
});
}, [allDocuments, documentCatalog.documents, selectedDocumentId]);
const selectedDocument = documents.find(doc => doc.id === selectedDocumentId);
const canContinue =
!!selectedDocument && !isDisabledState(selectedDocument.state);
// Get document type for the proof request message
const selectedDocumentType = useMemo(() => {
// If we have a preloaded document type from route params, use it while loading
const preloadedType = route.params?.documentType;
if (loading && preloadedType) {
return preloadedType;
}
if (!selectedDocumentId) return preloadedType || '';
const metadata = documentCatalog.documents.find(
d => d.id === selectedDocumentId,
);
return getDocumentTypeName(metadata?.documentCategory);
}, [
selectedDocumentId,
documentCatalog.documents,
loading,
route.params?.documentType,
]);
const handleSelect = useCallback((documentId: string) => {
setSelectedDocumentId(documentId);
}, []);
const handleSheetSelect = useCallback(async () => {
if (!selectedDocumentId || !canContinue || submitting) {
return;
}
setSubmitting(true);
setError(null);
try {
await setSelectedDocument(selectedDocumentId);
setSheetOpen(false); // Close the sheet first
navigation.navigate('Prove', { scrollOffset: scrollOffsetRef.current });
} catch (selectionError) {
console.error('Failed to set selected document:', selectionError);
setError('Failed to select document. Please try again.');
} finally {
setSubmitting(false);
}
}, [
selectedDocumentId,
canContinue,
submitting,
setSelectedDocument,
navigation,
]);
const handleApprove = async () => {
if (!selectedDocumentId || !canContinue || submitting) {
return;
}
setSubmitting(true);
setError(null);
try {
await setSelectedDocument(selectedDocumentId);
navigation.navigate('Prove', { scrollOffset: scrollOffsetRef.current });
} catch (selectionError) {
console.error('Failed to set selected document:', selectionError);
setError('Failed to select document. Please try again.');
} finally {
setSubmitting(false);
}
};
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
},
[],
);
// Loading state
if (loading) {
return (
<View
flex={1}
backgroundColor={proofRequestColors.white}
alignItems="center"
justifyContent="center"
testID="document-selector-loading-container"
>
<ActivityIndicator color={blue600} size="large" />
</View>
);
}
// Error state
if (error) {
return (
<View
flex={1}
backgroundColor={proofRequestColors.white}
alignItems="center"
justifyContent="center"
gap={16}
>
<Text
fontFamily={dinot}
fontSize={16}
color={proofRequestColors.slate500}
textAlign="center"
testID="document-selector-error"
>
{error}
</Text>
<View
paddingHorizontal={24}
paddingVertical={12}
borderRadius={8}
borderWidth={1}
borderColor={proofRequestColors.slate200}
onPress={loadDocuments}
pressStyle={{ opacity: 0.7 }}
testID="document-selector-retry"
>
<Text
fontFamily={dinot}
fontSize={16}
color={proofRequestColors.slate500}
>
Retry
</Text>
</View>
</View>
);
}
// Empty state
if (documents.length === 0) {
return (
<View
flex={1}
backgroundColor={proofRequestColors.white}
alignItems="center"
justifyContent="center"
>
<Text
fontFamily={dinot}
fontSize={16}
color={proofRequestColors.slate500}
textAlign="center"
paddingHorizontal={40}
testID="document-selector-empty"
>
No documents found. Please scan a document first.
</Text>
</View>
);
}
return (
<View style={[styles.container, { paddingTop: 0 }]}>
{/* Main Content - Proof Request Card */}
<ProofRequestCard
logoSource={logoSource}
appName={selfApp?.appName || 'Self'}
appUrl={url}
documentType={selectedDocumentType}
onScroll={handleScroll}
testID="document-selector-card"
>
{/* Connected Wallet Badge */}
{formattedUserId && (
<ConnectedWalletBadge
address={
selfApp?.userIdType === 'hex'
? truncateAddress(selfApp?.userId || '')
: formattedUserId
}
userIdType={selfApp?.userIdType}
onToggle={() => setWalletModalOpen(true)}
testID="document-selector-wallet-badge"
/>
)}
{/* Disclosure Items */}
<YStack marginTop={formattedUserId ? 16 : 0}>
{disclosureItems.map((item, index) => (
<DisclosureItem
key={item.key}
text={item.text}
verified={true}
isLast={index === disclosureItems.length - 1}
testID={`document-selector-disclosure-${item.key}`}
/>
))}
</YStack>
</ProofRequestCard>
{/* Bottom Action Bar */}
<BottomActionBar
selectedDocumentName={selectedDocument?.name || 'Select ID'}
onDocumentSelectorPress={() => setSheetOpen(true)}
onApprovePress={handleApprove}
approveDisabled={!canContinue}
approving={submitting}
testID="document-selector-action-bar"
/>
{/* ID Selector Sheet */}
<IDSelectorSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
documents={documents}
selectedId={selectedDocumentId}
onSelect={handleSelect}
onDismiss={() => setSheetOpen(false)}
onApprove={handleSheetSelect}
testID="document-selector-sheet"
/>
{/* Wallet Address Modal */}
{formattedUserId && selfApp?.userId && (
<WalletAddressModal
visible={walletModalOpen}
onClose={() => setWalletModalOpen(false)}
address={selfApp.userId}
userIdType={selfApp?.userIdType}
testID="document-selector-wallet-modal"
/>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: white,
},
});
export { DocumentSelectorForProvingScreen };

View File

@@ -2,7 +2,6 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import LottieView from 'lottie-react-native';
import React, {
useCallback,
useEffect,
@@ -14,33 +13,34 @@ import type {
LayoutChangeEvent,
NativeScrollEvent,
NativeSyntheticEvent,
ScrollView as ScrollViewType,
} from 'react-native';
import { ScrollView, StyleSheet, TouchableOpacity } from 'react-native';
import { Image, Text, View, XStack, YStack } from 'tamagui';
import { useIsFocused, useNavigation } from '@react-navigation/native';
import { StyleSheet } from 'react-native';
import { View, YStack } from 'tamagui';
import type { RouteProp } from '@react-navigation/native';
import {
useIsFocused,
useNavigation,
useRoute,
} from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Eye, EyeOff } from '@tamagui/lucide-icons';
import { isMRZDocument } from '@selfxyz/common';
import type { SelfAppDisclosureConfig } from '@selfxyz/common/utils/appType';
import { formatEndpoint } from '@selfxyz/common/utils/scope';
import { loadSelectedDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import miscAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/misc.json';
import {
BodyText,
Caption,
HeldPrimaryButtonProveScreen,
} from '@selfxyz/mobile-sdk-alpha/components';
import { ProofEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import {
black,
slate300,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import Disclosures from '@/components/Disclosures';
import {
BottomVerifyBar,
ConnectedWalletBadge,
DisclosureItem,
ProofRequestCard,
proofRequestColors,
truncateAddress,
WalletAddressModal,
} from '@/components/proof-request';
import { useSelfAppData } from '@/hooks/useSelfAppData';
import { useSelfAppStalenessCheck } from '@/hooks/useSelfAppStalenessCheck';
import { buttonTap } from '@/integrations/haptics';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import type { RootStackParamList } from '@/navigation';
import {
setDefaultDocumentTypeIfNeeded,
@@ -56,26 +56,41 @@ import {
checkDocumentExpiration,
getDocumentAttributes,
} from '@/utils/documentAttributes';
import { formatUserId } from '@/utils/formatUserId';
import { getDocumentTypeName } from '@/utils/documentUtils';
const ProveScreen: React.FC = () => {
const selfClient = useSelfClient();
const { trackEvent } = selfClient;
const { navigate } =
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { navigate } = navigation;
const route = useRoute<RouteProp<RootStackParamList, 'Prove'>>();
const isFocused = useIsFocused();
const { useProvingStore, useSelfAppStore } = selfClient;
const selectedApp = useSelfAppStore(state => state.selfApp);
// Extract SelfApp data using hook
const { logoSource, url, formattedUserId, disclosureItems } =
useSelfAppData(selectedApp);
// Check for stale data and navigate to Home if needed
useSelfAppStalenessCheck(
selectedApp,
disclosureItems,
navigation as NativeStackNavigationProp<RootStackParamList>,
);
const selectedAppRef = useRef<typeof selectedApp>(null);
const processedSessionsRef = useRef<Set<string>>(new Set());
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
const [scrollViewContentHeight, setScrollViewContentHeight] = useState(0);
const [scrollViewHeight, setScrollViewHeight] = useState(0);
const [showFullAddress, setShowFullAddress] = useState(false);
const [hasLayoutMeasurements, setHasLayoutMeasurements] = useState(false);
const [isDocumentExpired, setIsDocumentExpired] = useState(false);
const [documentType, setDocumentType] = useState('');
const [walletModalOpen, setWalletModalOpen] = useState(false);
const isDocumentExpiredRef = useRef(false);
const scrollViewRef = useRef<ScrollView>(null);
const scrollViewRef = useRef<ScrollViewType>(null);
const isContentShorterThanScrollView = useMemo(
() => scrollViewContentHeight <= scrollViewHeight,
@@ -92,6 +107,7 @@ const ProveScreen: React.FC = () => {
const addHistory = async () => {
if (provingStore.uuid && selectedApp) {
const catalog = await loadDocumentCatalog();
const selectedDocumentId = catalog.selectedDocumentId;
addProofHistory({
@@ -109,15 +125,18 @@ const ProveScreen: React.FC = () => {
}
};
addHistory();
}, [addProofHistory, provingStore.uuid, selectedApp, loadDocumentCatalog]);
}, [addProofHistory, loadDocumentCatalog, provingStore.uuid, selectedApp]);
useEffect(() => {
if (isContentShorterThanScrollView) {
setHasScrolledToBottom(true);
} else {
setHasScrolledToBottom(false);
// Only update hasScrolledToBottom once we have real layout measurements
if (hasLayoutMeasurements) {
if (isContentShorterThanScrollView) {
setHasScrolledToBottom(true);
} else {
setHasScrolledToBottom(false);
}
}
}, [isContentShorterThanScrollView]);
}, [isContentShorterThanScrollView, hasLayoutMeasurements]);
useEffect(() => {
if (!isFocused || !selectedApp) {
@@ -142,6 +161,9 @@ const ProveScreen: React.FC = () => {
setIsDocumentExpired(isExpired);
isDocumentExpiredRef.current = isExpired;
}
setDocumentType(
getDocumentTypeName(selectedDocument?.data?.documentCategory),
);
} catch (error) {
console.error('Error checking document expiration:', error);
setIsDocumentExpired(false);
@@ -212,43 +234,6 @@ const ProveScreen: React.FC = () => {
enhanceApp();
}, [selectedApp, selfClient]);
const disclosureOptions = useMemo(() => {
return (selectedApp?.disclosures as SelfAppDisclosureConfig) || [];
}, [selectedApp?.disclosures]);
// Format the logo source based on whether it's a URL or base64 string
const logoSource = useMemo(() => {
if (!selectedApp?.logoBase64) {
return null;
}
// Check if the logo is already a URL
if (
selectedApp.logoBase64.startsWith('http://') ||
selectedApp.logoBase64.startsWith('https://')
) {
return { uri: selectedApp.logoBase64 };
}
// Otherwise handle as base64 as before
const base64String = selectedApp.logoBase64.startsWith('data:image')
? selectedApp.logoBase64
: `data:image/png;base64,${selectedApp.logoBase64}`;
return { uri: base64String };
}, [selectedApp?.logoBase64]);
const url = useMemo(() => {
if (!selectedApp?.endpoint) {
return null;
}
return formatEndpoint(selectedApp.endpoint);
}, [selectedApp?.endpoint]);
const formattedUserId = useMemo(
() => formatUserId(selectedApp?.userId, selectedApp?.userIdType),
[selectedApp?.userId, selectedApp?.userIdType],
);
function onVerify() {
provingStore.setUserConfirmed(selfClient);
buttonTap();
@@ -299,218 +284,99 @@ const ProveScreen: React.FC = () => {
const handleContentSizeChange = useCallback(
(contentWidth: number, contentHeight: number) => {
setScrollViewContentHeight(contentHeight);
// If we now have both measurements and content fits on screen, enable button immediately
if (contentHeight > 0 && scrollViewHeight > 0) {
setHasLayoutMeasurements(true);
if (contentHeight <= scrollViewHeight) {
setHasScrolledToBottom(true);
}
}
},
[],
[scrollViewHeight],
);
const handleScrollViewLayout = useCallback((event: LayoutChangeEvent) => {
setScrollViewHeight(event.nativeEvent.layout.height);
}, []);
const handleAddressToggle = useCallback(() => {
if (selectedApp?.userIdType === 'hex') {
setShowFullAddress(!showFullAddress);
buttonTap();
}
}, [selectedApp?.userIdType, showFullAddress]);
const handleScrollViewLayout = useCallback(
(event: LayoutChangeEvent) => {
const layoutHeight = event.nativeEvent.layout.height;
setScrollViewHeight(layoutHeight);
// If we now have both measurements and content fits on screen, enable button immediately
if (layoutHeight > 0 && scrollViewContentHeight > 0) {
setHasLayoutMeasurements(true);
if (scrollViewContentHeight <= layoutHeight) {
setHasScrolledToBottom(true);
}
}
},
[scrollViewContentHeight],
);
return (
<ExpandableBottomLayout.Layout flex={1} backgroundColor={black}>
<ExpandableBottomLayout.TopSection backgroundColor={black}>
<YStack alignItems="center">
{!selectedApp?.sessionId ? (
<LottieView
source={miscAnimation}
autoPlay
loop
resizeMode="cover"
cacheComposition={true}
renderMode="HARDWARE"
style={styles.animation}
speed={1}
progress={0}
/>
) : (
<YStack alignItems="center" justifyContent="center">
{logoSource && (
<Image
marginBottom={20}
source={logoSource}
width={64}
height={64}
objectFit="contain"
/>
)}
<BodyText
style={{ fontSize: 12, color: slate300, marginBottom: 20 }}
>
{url}
</BodyText>
<BodyText
style={{ fontSize: 24, color: slate300, textAlign: 'center' }}
>
<Text color={white}>{selectedApp.appName}</Text> is requesting
you to prove the following information:
</BodyText>
</YStack>
)}
</YStack>
</ExpandableBottomLayout.TopSection>
<ExpandableBottomLayout.BottomSection
paddingBottom={20}
backgroundColor={white}
maxHeight={'55%'}
<View style={styles.container}>
<ProofRequestCard
logoSource={logoSource}
appName={selectedApp?.appName || 'Self'}
appUrl={url}
documentType={documentType}
onScroll={handleScroll}
scrollViewRef={scrollViewRef}
onContentSizeChange={handleContentSizeChange}
onLayout={handleScrollViewLayout}
initialScrollOffset={route.params?.scrollOffset}
testID="prove-screen-card"
>
<ScrollView
ref={scrollViewRef}
onScroll={handleScroll}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
onLayout={handleScrollViewLayout}
>
<Disclosures disclosures={disclosureOptions} />
{formattedUserId && (
<ConnectedWalletBadge
address={
selectedApp?.userIdType === 'hex'
? truncateAddress(selectedApp?.userId || '')
: formattedUserId
}
userIdType={selectedApp?.userIdType}
onToggle={() => setWalletModalOpen(true)}
testID="prove-screen-wallet-badge"
/>
)}
{/* Display connected wallet or UUID */}
{formattedUserId && (
<View marginTop={20} paddingHorizontal={20}>
<BodyText
style={{
fontSize: 16,
color: black,
fontWeight: '600',
marginBottom: 10,
}}
>
{selectedApp?.userIdType === 'hex'
? 'Connected Wallet'
: 'Connected ID'}
:
</BodyText>
<TouchableOpacity
onPress={handleAddressToggle}
activeOpacity={selectedApp?.userIdType === 'hex' ? 0.7 : 1}
style={{ minHeight: 44 }}
>
<View
backgroundColor={slate300}
padding={15}
borderRadius={8}
marginBottom={10}
>
<XStack alignItems="center" justifyContent="space-between">
<View
flex={1}
marginRight={selectedApp?.userIdType === 'hex' ? 12 : 0}
>
<BodyText
style={{
fontSize: 14,
color: black,
lineHeight: 20,
...(showFullAddress &&
selectedApp?.userIdType === 'hex'
? { fontFamily: 'monospace' }
: {}),
flexWrap: showFullAddress ? 'wrap' : 'nowrap',
}}
>
{selectedApp?.userIdType === 'hex' && showFullAddress
? selectedApp.userId
: formattedUserId}
</BodyText>
</View>
{selectedApp?.userIdType === 'hex' && (
<View alignItems="center" justifyContent="center">
{showFullAddress ? (
<EyeOff size={16} color={black} />
) : (
<Eye size={16} color={black} />
)}
</View>
)}
</XStack>
{selectedApp?.userIdType === 'hex' && (
<BodyText
style={{
fontSize: 12,
color: black,
opacity: 0.6,
marginTop: 4,
}}
>
{showFullAddress
? 'Tap to hide address'
: 'Tap to show full address'}
</BodyText>
)}
</View>
</TouchableOpacity>
</View>
)}
<YStack marginTop={formattedUserId ? 16 : 0}>
{disclosureItems.map((item, index) => (
<DisclosureItem
key={item.key}
text={item.text}
verified={true}
isLast={index === disclosureItems.length - 1}
testID={`prove-screen-disclosure-${item.key}`}
/>
))}
</YStack>
</ProofRequestCard>
{/* Display userDefinedData if it exists */}
{selectedApp?.userDefinedData && (
<View marginTop={20} paddingHorizontal={20}>
<BodyText
style={{
fontSize: 16,
color: black,
fontWeight: '600',
marginBottom: 10,
}}
>
Additional Information:
</BodyText>
<View
backgroundColor={slate300}
padding={15}
borderRadius={8}
marginBottom={10}
>
<BodyText
style={{ fontSize: 14, color: black, lineHeight: 20 }}
>
{selectedApp.userDefinedData}
</BodyText>
</View>
</View>
)}
<BottomVerifyBar
onVerify={onVerify}
selectedAppSessionId={selectedApp?.sessionId}
hasScrolledToBottom={hasScrolledToBottom}
isReadyToProve={isReadyToProve}
isDocumentExpired={isDocumentExpired}
testID="prove-screen-verify-bar"
/>
<View marginTop={20}>
<Caption
style={{
textAlign: 'center',
fontSize: 12,
marginBottom: 20,
marginTop: 10,
borderRadius: 4,
paddingBottom: 20,
}}
>
Self will confirm that these details are accurate and none of your
confidential info will be revealed to {selectedApp?.appName}
</Caption>
</View>
</ScrollView>
<HeldPrimaryButtonProveScreen
onVerify={onVerify}
selectedAppSessionId={selectedApp?.sessionId}
hasScrolledToBottom={hasScrolledToBottom}
isReadyToProve={isReadyToProve}
isDocumentExpired={isDocumentExpired}
{formattedUserId && selectedApp?.userId && (
<WalletAddressModal
visible={walletModalOpen}
onClose={() => setWalletModalOpen(false)}
address={selectedApp.userId}
userIdType={selectedApp?.userIdType}
testID="prove-screen-wallet-modal"
/>
</ExpandableBottomLayout.BottomSection>
</ExpandableBottomLayout.Layout>
)}
</View>
);
};
export default ProveScreen;
const styles = StyleSheet.create({
animation: {
top: 0,
width: 200,
height: 200,
transform: [{ scale: 2 }, { translateY: -20 }],
container: {
flex: 1,
backgroundColor: proofRequestColors.white,
},
});

View File

@@ -0,0 +1,205 @@
// 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 { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityIndicator } from 'react-native';
import { Text, View } from 'tamagui';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
isDocumentValidForProving,
pickBestDocumentToSelect,
} from '@selfxyz/mobile-sdk-alpha';
import { blue600 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { proofRequestColors } from '@/components/proof-request';
import type { RootStackParamList } from '@/navigation';
import { usePassport } from '@/providers/passportDataProvider';
import { useSettingStore } from '@/stores/settingStore';
import { getDocumentTypeName } from '@/utils/documentUtils';
/**
* Router screen for the proving flow that decides whether to skip the document selector.
*
* This screen:
* 1. Loads document catalog and counts valid documents
* 2. Checks skip settings (skipDocumentSelector, skipDocumentSelectorIfSingle)
* 3. Routes to appropriate screen:
* - No valid documents -> DocumentDataNotFound
* - Skip enabled -> auto-select and go to Prove
* - Otherwise -> DocumentSelectorForProving
*/
const ProvingScreenRouter: React.FC = () => {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { loadDocumentCatalog, getAllDocuments, setSelectedDocument } =
usePassport();
const { skipDocumentSelector, skipDocumentSelectorIfSingle } =
useSettingStore();
const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const hasRoutedRef = useRef(false);
const loadAndRoute = useCallback(async () => {
// Cancel any in-flight request
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
// Prevent double routing
if (hasRoutedRef.current) {
return;
}
setError(null);
try {
const catalog = await loadDocumentCatalog();
const docs = await getAllDocuments();
// Don't continue if this request was aborted
if (controller.signal.aborted) {
return;
}
// Count valid documents
const validDocuments = catalog.documents.filter(doc => {
const docData = docs[doc.id];
return isDocumentValidForProving(doc, docData?.data);
});
const validCount = validDocuments.length;
// Mark as routed to prevent re-routing
hasRoutedRef.current = true;
// Route based on document availability and skip settings
if (validCount === 0) {
// No valid documents - redirect to onboarding
navigation.replace('DocumentDataNotFound');
return;
}
// Determine document type from first valid document for display
const firstValidDoc = validDocuments[0];
const documentType = getDocumentTypeName(firstValidDoc?.documentCategory);
// Determine if we should skip the selector
const shouldSkip =
skipDocumentSelector ||
(skipDocumentSelectorIfSingle && validCount === 1);
if (shouldSkip) {
// Auto-select and navigate to Prove
const docToSelect = pickBestDocumentToSelect(catalog, docs);
if (docToSelect) {
try {
await setSelectedDocument(docToSelect);
navigation.replace('Prove');
} catch (selectError) {
console.error('Failed to auto-select document:', selectError);
// On error, fall back to showing the selector
hasRoutedRef.current = false;
navigation.replace('DocumentSelectorForProving', {
documentType,
});
}
} else {
// No valid document to select, show selector
navigation.replace('DocumentSelectorForProving', {
documentType,
});
}
} else {
// Show the document selector
navigation.replace('DocumentSelectorForProving', {
documentType,
});
}
} catch (loadError) {
// Don't show error if this request was aborted
if (controller.signal.aborted) {
return;
}
console.warn('Failed to load documents for routing:', loadError);
setError('Unable to load documents.');
// Reset routed flag to allow retry
hasRoutedRef.current = false;
}
}, [
getAllDocuments,
loadDocumentCatalog,
navigation,
setSelectedDocument,
skipDocumentSelector,
skipDocumentSelectorIfSingle,
]);
useFocusEffect(
useCallback(() => {
// Reset routing flag when screen gains focus
hasRoutedRef.current = false;
loadAndRoute();
}, [loadAndRoute]),
);
// Cleanup abort controller on unmount
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);
return (
<View
flex={1}
backgroundColor={proofRequestColors.white}
alignItems="center"
justifyContent="center"
testID="proving-router-container"
>
{error ? (
<View alignItems="center" gap={16}>
<Text
fontFamily={dinot}
fontSize={16}
color={proofRequestColors.slate500}
textAlign="center"
testID="proving-router-error"
>
{error}
</Text>
<View
paddingHorizontal={24}
paddingVertical={12}
borderRadius={8}
borderWidth={1}
borderColor={proofRequestColors.slate200}
onPress={() => {
hasRoutedRef.current = false;
loadAndRoute();
}}
pressStyle={{ opacity: 0.7 }}
testID="proving-router-retry"
>
<Text
fontFamily={dinot}
fontSize={16}
color={proofRequestColors.slate500}
>
Retry
</Text>
</View>
</View>
) : (
<>
<ActivityIndicator color={blue600} size="large" />
</>
)}
</View>
);
};
export { ProvingScreenRouter };

View File

@@ -48,7 +48,7 @@ const QRCodeViewFinderScreen: React.FC = () => {
const isFocused = useIsFocused();
const [doneScanningQR, setDoneScanningQR] = useState(false);
const { top: safeAreaTop } = useSafeAreaInsets();
const navigateToProve = useHapticNavigation('Prove');
const navigateToDocumentSelector = useHapticNavigation('ProvingScreenRouter');
// This resets to the default state when we navigate back to this screen
useFocusEffect(
@@ -91,7 +91,7 @@ const QRCodeViewFinderScreen: React.FC = () => {
.startAppListener(selfAppJson.sessionId);
setTimeout(() => {
navigateToProve();
navigateToDocumentSelector();
}, 100);
} catch (parseError) {
trackEvent(ProofEvents.QR_SCAN_FAILED, {
@@ -115,7 +115,7 @@ const QRCodeViewFinderScreen: React.FC = () => {
selfClient.getSelfAppState().startAppListener(sessionId);
setTimeout(() => {
navigateToProve();
navigateToDocumentSelector();
}, 100);
} else {
trackEvent(ProofEvents.QR_SCAN_FAILED, {
@@ -129,7 +129,13 @@ const QRCodeViewFinderScreen: React.FC = () => {
}
}
},
[doneScanningQR, navigation, navigateToProve, trackEvent, selfClient],
[
doneScanningQR,
navigation,
navigateToDocumentSelector,
trackEvent,
selfClient,
],
);
const shouldRenderCamera = !connectionModalVisible && !doneScanningQR;

View File

@@ -34,8 +34,12 @@ interface PersistedSettingsState {
setKeychainMigrationCompleted: () => void;
setLoggingSeverity: (severity: LoggingSeverity) => void;
setPointsAddress: (address: string | null) => void;
setSkipDocumentSelector: (value: boolean) => void;
setSkipDocumentSelectorIfSingle: (value: boolean) => void;
setSubscribedTopics: (topics: string[]) => void;
setTurnkeyBackupEnabled: (turnkeyBackupEnabled: boolean) => void;
skipDocumentSelector: boolean;
skipDocumentSelectorIfSingle: boolean;
subscribedTopics: string[];
toggleCloudBackupEnabled: () => void;
turnkeyBackupEnabled: boolean;
@@ -135,6 +139,14 @@ export const useSettingStore = create<SettingsState>()(
setPointsAddress: (address: string | null) =>
set({ pointsAddress: address }),
// Document selector skip settings
skipDocumentSelector: false,
setSkipDocumentSelector: (value: boolean) =>
set({ skipDocumentSelector: value }),
skipDocumentSelectorIfSingle: true,
setSkipDocumentSelectorIfSingle: (value: boolean) =>
set({ skipDocumentSelectorIfSingle: value }),
// Non-persisted state (will not be saved to storage)
hideNetworkModal: false,
setHideNetworkModal: (hideNetworkModal: boolean) => {

View File

@@ -8,4 +8,4 @@
* Use this constant instead of checking __DEV__ directly throughout the codebase.
*/
export const IS_DEV_MODE = typeof __DEV__ !== 'undefined' && __DEV__;
export const IS_EUCLID_ENABLED = false; //IS_DEV_MODE; // just in case we forgot to turn it off before pushing to prod.
export const IS_EUCLID_ENABLED = false; // Enabled for proof request UI redesign

View File

@@ -0,0 +1,89 @@
// 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 type { Country3LetterCode } from '@selfxyz/common/constants';
import { countryCodes } from '@selfxyz/common/constants';
import type { SelfAppDisclosureConfig } from '@selfxyz/common/utils/appType';
function listToString(list: string[]): string {
if (list.length === 1) {
return list[0];
} else if (list.length === 2) {
return list.join(' nor ');
}
return `${list.slice(0, -1).join(', ')} nor ${list.at(-1)}`;
}
function countriesToSentence(countries: Country3LetterCode[]): string {
return listToString(countries.map(country => countryCodes[country]));
}
export const ORDERED_DISCLOSURE_KEYS: Array<keyof SelfAppDisclosureConfig> = [
'issuing_state',
'name',
'passport_number',
'nationality',
'date_of_birth',
'gender',
'expiry_date',
'ofac',
'excludedCountries',
'minimumAge',
] as const;
export function getDisclosureItems(
disclosures: SelfAppDisclosureConfig,
): Array<{ key: string; text: string }> {
const items: Array<{ key: string; text: string }> = [];
for (const key of ORDERED_DISCLOSURE_KEYS) {
const isEnabled = disclosures[key];
if (!isEnabled || (Array.isArray(isEnabled) && isEnabled.length === 0)) {
continue;
}
const text = getDisclosureText(key, disclosures);
if (text) {
items.push({ key, text });
}
}
return items;
}
/**
* Generates the display text for a disclosure key.
* This is the single source of truth for disclosure text across the app.
*/
export function getDisclosureText(
key: keyof SelfAppDisclosureConfig,
disclosures: SelfAppDisclosureConfig,
): string {
switch (key) {
case 'ofac':
return 'I am not on the OFAC sanction list';
case 'excludedCountries':
return `I am not a citizen of the following countries: ${countriesToSentence(
(disclosures.excludedCountries as Country3LetterCode[]) || [],
)}`;
case 'minimumAge':
return `Age is over ${disclosures.minimumAge}`;
case 'name':
return 'Name';
case 'passport_number':
return 'Passport Number';
case 'date_of_birth':
return 'Date of Birth';
case 'gender':
return 'Gender';
case 'expiry_date':
return 'Passport Expiry Date';
case 'issuing_state':
return 'Issuing State';
case 'nationality':
return 'Nationality';
default:
return '';
}
}

View File

@@ -0,0 +1,19 @@
// 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.
/**
* Gets the document type display name for the proof request message.
*/
export function getDocumentTypeName(category: string | undefined): string {
switch (category) {
case 'passport':
return 'Passport';
case 'id_card':
return 'ID Card';
case 'aadhaar':
return 'Aadhaar';
default:
return 'Document';
}
}

View File

@@ -39,6 +39,9 @@ export { extraYPadding, normalizeBorderWidth } from '@/utils/styleUtils';
// JSON utilities
export { formatUserId } from '@/utils/formatUserId';
// Document utilities
export { getDocumentTypeName } from '@/utils/documentUtils';
export {
getModalCallbacks,
registerModalCallbacks,