mirror of
https://github.com/selfxyz/self.git
synced 2026-02-19 02:24:25 -05:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
132
app/src/components/documents/IDSelectorItem.tsx
Normal file
132
app/src/components/documents/IDSelectorItem.tsx
Normal 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';
|
||||
}
|
||||
174
app/src/components/documents/IDSelectorSheet.tsx
Normal file
174
app/src/components/documents/IDSelectorSheet.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
18
app/src/components/documents/index.ts
Normal file
18
app/src/components/documents/index.ts
Normal 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';
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
170
app/src/components/proof-request/BottomActionBar.tsx
Normal file
170
app/src/components/proof-request/BottomActionBar.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
49
app/src/components/proof-request/BottomVerifyBar.tsx
Normal file
49
app/src/components/proof-request/BottomVerifyBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
103
app/src/components/proof-request/ConnectedWalletBadge.tsx
Normal file
103
app/src/components/proof-request/ConnectedWalletBadge.tsx
Normal 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)}`;
|
||||
}
|
||||
85
app/src/components/proof-request/DisclosureItem.tsx
Normal file
85
app/src/components/proof-request/DisclosureItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
89
app/src/components/proof-request/ProofMetadataBar.tsx
Normal file
89
app/src/components/proof-request/ProofMetadataBar.tsx
Normal 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}`;
|
||||
}
|
||||
138
app/src/components/proof-request/ProofRequestCard.tsx
Normal file
138
app/src/components/proof-request/ProofRequestCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
104
app/src/components/proof-request/ProofRequestHeader.tsx
Normal file
104
app/src/components/proof-request/ProofRequestHeader.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
240
app/src/components/proof-request/WalletAddressModal.tsx
Normal file
240
app/src/components/proof-request/WalletAddressModal.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
40
app/src/components/proof-request/designTokens.ts
Normal file
40
app/src/components/proof-request/designTokens.ts
Normal 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;
|
||||
143
app/src/components/proof-request/icons.tsx
Normal file
143
app/src/components/proof-request/icons.tsx
Normal 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>
|
||||
);
|
||||
68
app/src/components/proof-request/index.ts
Normal file
68
app/src/components/proof-request/index.ts
Normal 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';
|
||||
@@ -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]);
|
||||
|
||||
|
||||
62
app/src/hooks/useSelfAppData.ts
Normal file
62
app/src/hooks/useSelfAppData.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
40
app/src/hooks/useSelfAppStalenessCheck.ts
Normal file
40
app/src/hooks/useSelfAppStalenessCheck.ts
Normal 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]),
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
130
app/src/screens/account/settings/ProofSettingsScreen.tsx
Normal file
130
app/src/screens/account/settings/ProofSettingsScreen.tsx
Normal 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 "Skip when only one
|
||||
document" 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 };
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
205
app/src/screens/verification/ProvingScreenRouter.tsx
Normal file
205
app/src/screens/verification/ProvingScreenRouter.tsx
Normal 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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
89
app/src/utils/disclosureUtils.ts
Normal file
89
app/src/utils/disclosureUtils.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
19
app/src/utils/documentUtils.ts
Normal file
19
app/src/utils/documentUtils.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user