diff --git a/app/src/components/proof-request/BottomVerifyBar.tsx b/app/src/components/proof-request/BottomVerifyBar.tsx new file mode 100644 index 000000000..13090742f --- /dev/null +++ b/app/src/components/proof-request/BottomVerifyBar.tsx @@ -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 = ({ + onVerify, + selectedAppSessionId, + hasScrolledToBottom, + isReadyToProve, + isDocumentExpired, + testID = 'bottom-verify-bar', +}) => { + const insets = useSafeAreaInsets(); + + return ( + + + + ); +}; diff --git a/app/src/components/proof-request/ProofRequestCard.tsx b/app/src/components/proof-request/ProofRequestCard.tsx index f72408ed3..42e1e6d12 100644 --- a/app/src/components/proof-request/ProofRequestCard.tsx +++ b/app/src/components/proof-request/ProofRequestCard.tsx @@ -3,7 +3,13 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. import React from 'react'; -import type { ImageSourcePropType } from 'react-native'; +import type { + ImageSourcePropType, + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView as ScrollViewType, +} from 'react-native'; import { ScrollView } from 'react-native'; import { Text, View } from 'tamagui'; @@ -27,6 +33,11 @@ export interface ProofRequestCardProps { timestamp?: Date; children?: React.ReactNode; testID?: string; + onScroll?: (event: NativeSyntheticEvent) => void; + scrollViewRef?: React.RefObject; + onContentSizeChange?: (width: number, height: number) => void; + onLayout?: (event: LayoutChangeEvent) => void; + initialScrollOffset?: number; } /** @@ -42,6 +53,11 @@ export const ProofRequestCard: React.FC = ({ timestamp = new Date(), children, testID = 'proof-request-card', + onScroll, + scrollViewRef, + onContentSizeChange, + onLayout, + initialScrollOffset, }) => { // Build request message with highlighted app name and document type const requestMessage = ( @@ -96,8 +112,18 @@ export const ProofRequestCard: React.FC = ({ borderBottomRightRadius={proofRequestSpacing.borderRadius} > {children} diff --git a/app/src/components/proof-request/ProofRequestHeader.tsx b/app/src/components/proof-request/ProofRequestHeader.tsx index 107e19b1c..e1ac8263a 100644 --- a/app/src/components/proof-request/ProofRequestHeader.tsx +++ b/app/src/components/proof-request/ProofRequestHeader.tsx @@ -34,6 +34,8 @@ export const ProofRequestHeader: React.FC = ({ requestMessage, testID = 'proof-request-header', }) => { + const hasLogo = logoSource !== null; + return ( = ({ {appName} {appUrl && ( - - {appUrl} - + + + {appUrl} + + )} diff --git a/app/src/components/proof-request/index.ts b/app/src/components/proof-request/index.ts index 1e61f8564..ac1503031 100644 --- a/app/src/components/proof-request/index.ts +++ b/app/src/components/proof-request/index.ts @@ -3,6 +3,7 @@ // 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'; @@ -28,6 +29,7 @@ export type { WalletAddressModalProps } from '@/components/proof-request/WalletA // Icons export { BottomActionBar } from '@/components/proof-request/BottomActionBar'; +export { BottomVerifyBar } from '@/components/proof-request/BottomVerifyBar'; // Bottom action bar export { diff --git a/app/src/navigation/index.tsx b/app/src/navigation/index.tsx index 03e91b9fa..c08ce82c1 100644 --- a/app/src/navigation/index.tsx +++ b/app/src/navigation/index.tsx @@ -151,7 +151,11 @@ export type RootStackParamList = Omit< ProofHistoryDetail: { data: ProofHistory; }; - Prove: undefined; + Prove: + | { + scrollOffset?: number; + } + | undefined; ProvingScreenRouter: undefined; DocumentSelectorForProving: undefined; diff --git a/app/src/navigation/verification.ts b/app/src/navigation/verification.ts index 085a8f2af..2fc49004e 100644 --- a/app/src/navigation/verification.ts +++ b/app/src/navigation/verification.ts @@ -47,7 +47,7 @@ const verificationScreens = { Prove: { screen: ProveScreen, options: { - title: 'Request Proof', + title: 'Proof Requested', headerStyle: { backgroundColor: black, }, diff --git a/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx b/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx index 7271a6b9f..1d342c9d7 100644 --- a/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx +++ b/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx @@ -9,13 +9,12 @@ import React, { useRef, useState, } from 'react'; +import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; import { ActivityIndicator, StyleSheet } from 'react-native'; import { Text, View, YStack } from 'tamagui'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import type { Country3LetterCode } from '@selfxyz/common/constants'; -import { countryCodes } from '@selfxyz/common/constants'; import { commonNames } from '@selfxyz/common/constants/countries'; import type { SelfAppDisclosureConfig } from '@selfxyz/common/utils/appType'; import { formatEndpoint } from '@selfxyz/common/utils/scope'; @@ -45,6 +44,7 @@ import { } from '@/components/proof-request'; import type { RootStackParamList } from '@/navigation'; import { usePassport } from '@/providers/passportDataProvider'; +import { getDisclosureItems } from '@/utils/disclosureUtils'; import { formatUserId } from '@/utils/formatUserId'; /** @@ -128,95 +128,6 @@ function determineDocumentState( return 'verified'; } -/** - * Converts a list of strings to a sentence with "nor" conjunctions. - */ -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)}`; -} - -/** - * Converts country codes to a readable sentence. - */ -function countriesToSentence(countries: Country3LetterCode[]): string { - return listToString(countries.map(country => countryCodes[country])); -} - -/** - * Generates disclosure items from the selfApp disclosure config. - */ -function getDisclosureItems( - disclosures: SelfAppDisclosureConfig, -): Array<{ key: string; text: string }> { - const ORDERED_KEYS: Array = [ - 'issuing_state', - 'name', - 'passport_number', - 'nationality', - 'date_of_birth', - 'gender', - 'expiry_date', - 'ofac', - 'excludedCountries', - 'minimumAge', - ] as const; - - const items: Array<{ key: string; text: string }> = []; - - for (const key of ORDERED_KEYS) { - const isEnabled = disclosures[key]; - if (!isEnabled || (Array.isArray(isEnabled) && isEnabled.length === 0)) { - continue; - } - - let text = ''; - switch (key) { - case 'ofac': - text = 'Not on the OFAC list'; - break; - case 'excludedCountries': - text = `Not a citizen of: ${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: - continue; - } - items.push({ key, text }); - } - - return items; -} - const DocumentSelectorForProvingScreen: React.FC = () => { const navigation = useNavigation>(); @@ -241,6 +152,7 @@ const DocumentSelectorForProvingScreen: React.FC = () => { const [sheetOpen, setSheetOpen] = useState(false); const [walletModalOpen, setWalletModalOpen] = useState(false); const abortControllerRef = useRef(null); + const scrollOffsetRef = useRef(0); // Memoized values from selfApp const logoSource = useMemo(() => { @@ -423,7 +335,7 @@ const DocumentSelectorForProvingScreen: React.FC = () => { setSheetOpen(false); try { await setSelectedDocument(selectedDocumentId); - navigation.navigate('Prove'); + navigation.navigate('Prove', { scrollOffset: scrollOffsetRef.current }); } catch (selectionError) { console.error('Failed to set selected document:', selectionError); setError('Failed to select document. Please try again.'); @@ -432,6 +344,13 @@ const DocumentSelectorForProvingScreen: React.FC = () => { } }; + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + scrollOffsetRef.current = event.nativeEvent.contentOffset.y; + }, + [], + ); + // Loading state if (loading) { return ( @@ -528,6 +447,7 @@ const DocumentSelectorForProvingScreen: React.FC = () => { appName={selfApp?.appName || 'Self'} appUrl={url} documentType={selectedDocumentType} + onScroll={handleScroll} testID="document-selector-card" > {/* Connected Wallet Badge */} diff --git a/app/src/screens/verification/ProveScreen.tsx b/app/src/screens/verification/ProveScreen.tsx index 57a0f3dbe..88531b3bf 100644 --- a/app/src/screens/verification/ProveScreen.tsx +++ b/app/src/screens/verification/ProveScreen.tsx @@ -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 { buttonTap } from '@/integrations/haptics'; -import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; import type { RootStackParamList } from '@/navigation'; import { setDefaultDocumentTypeIfNeeded, @@ -52,17 +52,32 @@ import { } from '@/services/points'; import { useProofHistoryStore } from '@/stores/proofHistoryStore'; import { ProofStatus } from '@/stores/proofTypes'; +import { getDisclosureItems } from '@/utils/disclosureUtils'; import { checkDocumentExpiration, getDocumentAttributes, } from '@/utils/documentAttributes'; import { formatUserId } from '@/utils/formatUserId'; +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'; + } +} + const ProveScreen: React.FC = () => { const selfClient = useSelfClient(); const { trackEvent } = selfClient; const { navigate } = useNavigation>(); + const route = useRoute>(); const isFocused = useIsFocused(); const { useProvingStore, useSelfAppStore } = selfClient; const selectedApp = useSelfAppStore(state => state.selfApp); @@ -72,10 +87,11 @@ const ProveScreen: React.FC = () => { const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false); const [scrollViewContentHeight, setScrollViewContentHeight] = useState(0); const [scrollViewHeight, setScrollViewHeight] = useState(0); - const [showFullAddress, setShowFullAddress] = useState(false); const [isDocumentExpired, setIsDocumentExpired] = useState(false); + const [documentType, setDocumentType] = useState('Document'); + const [walletModalOpen, setWalletModalOpen] = useState(false); const isDocumentExpiredRef = useRef(false); - const scrollViewRef = useRef(null); + const scrollViewRef = useRef(null); const isContentShorterThanScrollView = useMemo( () => scrollViewContentHeight <= scrollViewHeight, @@ -142,6 +158,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,9 +231,13 @@ const ProveScreen: React.FC = () => { enhanceApp(); }, [selectedApp, selfClient]); - const disclosureOptions = useMemo(() => { - return (selectedApp?.disclosures as SelfAppDisclosureConfig) || []; - }, [selectedApp?.disclosures]); + const disclosureItems = useMemo( + () => + getDisclosureItems( + (selectedApp?.disclosures as SelfAppDisclosureConfig) || {}, + ), + [selectedApp?.disclosures], + ); // Format the logo source based on whether it's a URL or base64 string const logoSource = useMemo(() => { @@ -307,210 +330,73 @@ const ProveScreen: React.FC = () => { setScrollViewHeight(event.nativeEvent.layout.height); }, []); - const handleAddressToggle = useCallback(() => { - if (selectedApp?.userIdType === 'hex') { - setShowFullAddress(!showFullAddress); - buttonTap(); - } - }, [selectedApp?.userIdType, showFullAddress]); - return ( - - - - {!selectedApp?.sessionId ? ( - + + {formattedUserId && ( + setWalletModalOpen(true)} + testID="prove-screen-wallet-badge" + /> + )} + + + {disclosureItems.map((item, index) => ( + - ) : ( - - {logoSource && ( - - )} - - {url} - - - {selectedApp.appName} is requesting - you to prove the following information: - - - )} + ))} - - - - - - {/* Display connected wallet or UUID */} - {formattedUserId && ( - - - {selectedApp?.userIdType === 'hex' - ? 'Connected Wallet' - : 'Connected ID'} - : - - - - - - - {selectedApp?.userIdType === 'hex' && showFullAddress - ? selectedApp.userId - : formattedUserId} - - - {selectedApp?.userIdType === 'hex' && ( - - {showFullAddress ? ( - - ) : ( - - )} - - )} - - {selectedApp?.userIdType === 'hex' && ( - - {showFullAddress - ? 'Tap to hide address' - : 'Tap to show full address'} - - )} - - - - )} - - {/* Display userDefinedData if it exists */} - {selectedApp?.userDefinedData && ( - - - Additional Information: - - - - {selectedApp.userDefinedData} - - - - )} - - - - Self will confirm that these details are accurate and none of your - confidential info will be revealed to {selectedApp?.appName} - - - - + + + + {formattedUserId && selectedApp?.userId && ( + setWalletModalOpen(false)} + address={selectedApp.userId} + userIdType={selectedApp?.userIdType} + testID="prove-screen-wallet-modal" /> - - + )} + ); }; 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, }, }); diff --git a/app/src/utils/disclosureUtils.ts b/app/src/utils/disclosureUtils.ts new file mode 100644 index 000000000..0bcdf20b2 --- /dev/null +++ b/app/src/utils/disclosureUtils.ts @@ -0,0 +1,84 @@ +// 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]; + 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 function getDisclosureItems( + disclosures: SelfAppDisclosureConfig, +): Array<{ key: string; text: string }> { + const ORDERED_KEYS: Array = [ + 'issuing_state', + 'name', + 'passport_number', + 'nationality', + 'date_of_birth', + 'gender', + 'expiry_date', + 'ofac', + 'excludedCountries', + 'minimumAge', + ] as const; + + const items: Array<{ key: string; text: string }> = []; + + for (const key of ORDERED_KEYS) { + const isEnabled = disclosures[key]; + if (!isEnabled || (Array.isArray(isEnabled) && isEnabled.length === 0)) { + continue; + } + + let text = ''; + switch (key) { + case 'ofac': + text = 'Not on the OFAC list'; + break; + case 'excludedCountries': + text = `Not a citizen of: ${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: + continue; + } + items.push({ key, text }); + } + + return items; +} diff --git a/app/tests/src/screens/verification/DocumentSelectorForProvingScreen.test.tsx b/app/tests/src/screens/verification/DocumentSelectorForProvingScreen.test.tsx index 6da08e6ee..3ecd0ae88 100644 --- a/app/tests/src/screens/verification/DocumentSelectorForProvingScreen.test.tsx +++ b/app/tests/src/screens/verification/DocumentSelectorForProvingScreen.test.tsx @@ -640,9 +640,7 @@ describe('DocumentSelectorForProvingScreen', () => { createAllDocuments([createDocumentEntry(passport)]), ); - const { getByTestId } = render( - , - ); + const { getByTestId } = render(); await waitFor(() => { expect(getByTestId('document-selector-action-bar')).toBeTruthy();