diff --git a/app/src/components/proof-request/BottomVerifyBar.tsx b/app/src/components/proof-request/BottomVerifyBar.tsx index 3c57a162d..0106b7cce 100644 --- a/app/src/components/proof-request/BottomVerifyBar.tsx +++ b/app/src/components/proof-request/BottomVerifyBar.tsx @@ -14,6 +14,7 @@ export interface BottomVerifyBarProps { onVerify: () => void; selectedAppSessionId: string | undefined | null; hasScrolledToBottom: boolean; + isScrollable: boolean; isReadyToProve: boolean; isDocumentExpired: boolean; testID?: string; @@ -23,6 +24,7 @@ export const BottomVerifyBar: React.FC = ({ onVerify, selectedAppSessionId, hasScrolledToBottom, + isScrollable, isReadyToProve, isDocumentExpired, testID = 'bottom-verify-bar', @@ -41,6 +43,7 @@ export const BottomVerifyBar: React.FC = ({ onVerify={onVerify} selectedAppSessionId={selectedAppSessionId} hasScrolledToBottom={hasScrolledToBottom} + isScrollable={isScrollable} isReadyToProve={isReadyToProve} isDocumentExpired={isDocumentExpired} /> diff --git a/app/src/components/proof-request/ProofRequestCard.tsx b/app/src/components/proof-request/ProofRequestCard.tsx index 7d50c734e..9d30dd389 100644 --- a/app/src/components/proof-request/ProofRequestCard.tsx +++ b/app/src/components/proof-request/ProofRequestCard.tsx @@ -32,6 +32,7 @@ export interface ProofRequestCardProps { documentType?: string; timestamp?: Date; children?: React.ReactNode; + connectedWalletBadge?: React.ReactNode; testID?: string; onScroll?: (event: NativeSyntheticEvent) => void; scrollViewRef?: React.RefObject; @@ -52,6 +53,7 @@ export const ProofRequestCard: React.FC = ({ documentType = '', timestamp, children, + connectedWalletBadge, testID = 'proof-request-card', onScroll, scrollViewRef, @@ -111,26 +113,47 @@ export const ProofRequestCard: React.FC = ({ - + {connectedWalletBadge} + + )} + + {/* Scrollable Content */} + - {children} - + + {children} + + diff --git a/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx b/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx index 12ce5a348..dfcaeea67 100644 --- a/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx +++ b/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx @@ -30,7 +30,7 @@ import { isDocumentValidForProving, useSelfClient, } from '@selfxyz/mobile-sdk-alpha'; -import { blue600, white } from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors'; import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; import type { IDSelectorState } from '@/components/documents'; @@ -341,7 +341,7 @@ const DocumentSelectorForProvingScreen: React.FC = () => { justifyContent="center" testID="document-selector-loading-container" > - + ); } @@ -418,25 +418,25 @@ const DocumentSelectorForProvingScreen: React.FC = () => { appName={selfApp?.appName || 'Self'} appUrl={url} documentType={selectedDocumentType} + connectedWalletBadge={ + formattedUserId ? ( + setWalletModalOpen(true)} + testID="document-selector-wallet-badge" + /> + ) : undefined + } onScroll={handleScroll} testID="document-selector-card" > - {/* Connected Wallet Badge */} - {formattedUserId && ( - setWalletModalOpen(true)} - testID="document-selector-wallet-badge" - /> - )} - {/* Disclosure Items */} - + {disclosureItems.map((item, index) => ( { const scrollViewRef = useRef(null); const isContentShorterThanScrollView = useMemo( - () => scrollViewContentHeight <= scrollViewHeight, + () => scrollViewContentHeight <= scrollViewHeight + 50, [scrollViewContentHeight, scrollViewHeight], ); + + const isScrollable = useMemo( + () => !isContentShorterThanScrollView && hasLayoutMeasurements, + [isContentShorterThanScrollView, hasLayoutMeasurements], + ); const provingStore = useProvingStore(); const currentState = useProvingStore(state => state.currentState); const isReadyToProve = currentState === 'ready_to_prove'; @@ -255,7 +260,7 @@ const ProveScreen: React.FC = () => { } const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent; - const paddingToBottom = 10; + const paddingToBottom = 50; const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom; @@ -287,7 +292,7 @@ const ProveScreen: React.FC = () => { // If we now have both measurements and content fits on screen, enable button immediately if (contentHeight > 0 && scrollViewHeight > 0) { setHasLayoutMeasurements(true); - if (contentHeight <= scrollViewHeight) { + if (contentHeight <= scrollViewHeight + 50) { setHasScrolledToBottom(true); } } @@ -302,7 +307,7 @@ const ProveScreen: React.FC = () => { // If we now have both measurements and content fits on screen, enable button immediately if (layoutHeight > 0 && scrollViewContentHeight > 0) { setHasLayoutMeasurements(true); - if (scrollViewContentHeight <= layoutHeight) { + if (scrollViewContentHeight <= layoutHeight + 50) { setHasScrolledToBottom(true); } } @@ -317,6 +322,20 @@ const ProveScreen: React.FC = () => { appName={selectedApp?.appName || 'Self'} appUrl={url} documentType={documentType} + connectedWalletBadge={ + formattedUserId ? ( + setWalletModalOpen(true)} + testID="prove-screen-wallet-badge" + /> + ) : undefined + } onScroll={handleScroll} scrollViewRef={scrollViewRef} onContentSizeChange={handleContentSizeChange} @@ -324,20 +343,8 @@ const ProveScreen: React.FC = () => { initialScrollOffset={route.params?.scrollOffset} testID="prove-screen-card" > - {formattedUserId && ( - setWalletModalOpen(true)} - testID="prove-screen-wallet-badge" - /> - )} - - + {/* Disclosure Items */} + {disclosureItems.map((item, index) => ( { onVerify={onVerify} selectedAppSessionId={selectedApp?.sessionId} hasScrolledToBottom={hasScrolledToBottom} + isScrollable={isScrollable} isReadyToProve={isReadyToProve} isDocumentExpired={isDocumentExpired} testID="prove-screen-verify-bar" diff --git a/app/src/screens/verification/ProvingScreenRouter.tsx b/app/src/screens/verification/ProvingScreenRouter.tsx index dc589853a..058297d20 100644 --- a/app/src/screens/verification/ProvingScreenRouter.tsx +++ b/app/src/screens/verification/ProvingScreenRouter.tsx @@ -12,7 +12,7 @@ import { isDocumentValidForProving, pickBestDocumentToSelect, } from '@selfxyz/mobile-sdk-alpha'; -import { blue600 } from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { black } from '@selfxyz/mobile-sdk-alpha/constants/colors'; import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; import { proofRequestColors } from '@/components/proof-request'; @@ -195,7 +195,7 @@ const ProvingScreenRouter: React.FC = () => { ) : ( <> - + )} diff --git a/app/src/stores/database.ts b/app/src/stores/database.ts index 717420019..87915d323 100644 --- a/app/src/stores/database.ts +++ b/app/src/stores/database.ts @@ -127,8 +127,9 @@ export const database: ProofDB = { proof.documentId, ], ); + // Handle case where INSERT OR IGNORE skips insertion due to duplicate sessionId return { - id: insertResult.insertId.toString(), + id: insertResult.insertId ? insertResult.insertId.toString() : '0', timestamp, rowsAffected: insertResult.rowsAffected, }; @@ -154,8 +155,9 @@ export const database: ProofDB = { proof.documentId, ], ); + // Handle case where INSERT OR IGNORE skips insertion due to duplicate sessionId return { - id: insertResult.insertId.toString(), + id: insertResult.insertId ? insertResult.insertId.toString() : '0', timestamp, rowsAffected: insertResult.rowsAffected, }; @@ -182,8 +184,9 @@ export const database: ProofDB = { proof.documentId, ], ); + // Handle case where INSERT OR IGNORE skips insertion due to duplicate sessionId return { - id: insertResult.insertId.toString(), + id: insertResult.insertId ? insertResult.insertId.toString() : '0', timestamp, rowsAffected: insertResult.rowsAffected, }; diff --git a/app/tests/src/stores/database.test.ts b/app/tests/src/stores/database.test.ts index 5d7807eb8..5f6293e74 100644 --- a/app/tests/src/stores/database.test.ts +++ b/app/tests/src/stores/database.test.ts @@ -173,6 +173,43 @@ describe('database (SQLite)', () => { rowsAffected: 1, }); }); + + it('handles duplicate sessionId gracefully (INSERT OR IGNORE skips)', async () => { + const mockProof = { + appName: 'TestApp', + sessionId: 'session-123', + userId: 'user-456', + userIdType: 'uuid' as const, + endpointType: 'https' as const, + status: ProofStatus.PENDING, + disclosures: '{"test": "data"}', + logoBase64: 'base64-logo', + documentId: 'document-123', + endpoint: 'https://example.com/endpoint', + }; + + // Simulate INSERT OR IGNORE behavior when a duplicate sessionId exists + const mockInsertResult = { + insertId: 0, // SQLite returns 0 for ignored inserts + rowsAffected: 0, + }; + + mockDb.executeSql.mockResolvedValueOnce([mockInsertResult]); + + const result = await database.insertProof(mockProof); + + expect(mockDb.executeSql).toHaveBeenCalledWith( + expect.stringContaining('INSERT OR IGNORE INTO proof_history'), + expect.any(Array), + ); + + // Should handle undefined/0 insertId gracefully + expect(result).toEqual({ + id: '0', + timestamp: expect.any(Number), + rowsAffected: 0, + }); + }); }); describe('updateProofStatus', () => { diff --git a/app/tests/src/stores/proofHistoryStore.test.ts b/app/tests/src/stores/proofHistoryStore.test.ts index 10d2c46d0..c34bf90ff 100644 --- a/app/tests/src/stores/proofHistoryStore.test.ts +++ b/app/tests/src/stores/proofHistoryStore.test.ts @@ -156,6 +156,35 @@ describe('proofHistoryStore', () => { expect(mockDatabase.insertProof).toHaveBeenCalledWith(mockProof); expect(useProofHistoryStore.getState().proofHistory).toHaveLength(0); }); + + it('handles duplicate insertion gracefully (rowsAffected = 0)', async () => { + const mockProof = { + appName: 'TestApp', + sessionId: 'session-123', + userId: 'user-456', + userIdType: 'uuid', + endpointType: 'celo', + status: ProofStatus.PENDING, + disclosures: '{"test": "data"}', + } as const; + + // Simulate INSERT OR IGNORE skipping the insertion due to duplicate sessionId + const mockInsertResult = { + id: '0', + timestamp: Date.now(), + rowsAffected: 0, + }; + + mockDatabase.insertProof.mockResolvedValue(mockInsertResult); + + await act(async () => { + await useProofHistoryStore.getState().addProofHistory(mockProof); + }); + + expect(mockDatabase.insertProof).toHaveBeenCalledWith(mockProof); + // Should not add to store when rowsAffected is 0 + expect(useProofHistoryStore.getState().proofHistory).toHaveLength(0); + }); }); describe('updateProofStatus', () => { diff --git a/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx b/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx index 337511349..352997407 100644 --- a/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx +++ b/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx @@ -18,6 +18,7 @@ interface HeldPrimaryButtonProveScreenProps { onVerify: () => void; selectedAppSessionId: string | undefined | null; hasScrolledToBottom: boolean; + isScrollable: boolean; isReadyToProve: boolean; isDocumentExpired: boolean; } @@ -76,7 +77,11 @@ const buttonMachine = createMachine( }, { target: 'preparing', - guard: ({ context }) => context.hasScrolledToBottom, + guard: ({ context }) => context.hasScrolledToBottom && !context.isReadyToProve, + }, + { + target: 'ready', + guard: ({ context }) => context.hasScrolledToBottom && context.isReadyToProve && !context.isDocumentExpired, }, ], }, @@ -96,7 +101,7 @@ const buttonMachine = createMachine( }, ], after: { - 500: { target: 'preparing2' }, + 100: { target: 'preparing2' }, }, }, preparing2: { @@ -115,7 +120,7 @@ const buttonMachine = createMachine( }, ], after: { - 500: { target: 'preparing3' }, + 100: { target: 'preparing3' }, }, }, preparing3: { @@ -195,6 +200,7 @@ export const HeldPrimaryButtonProveScreen: React.FC { @@ -212,57 +218,42 @@ export const HeldPrimaryButtonProveScreen: React.FC = ({ text }) => ( + + + {text} + + ); const renderButtonContent = () => { if (isDocumentExpired) { return 'Document expired'; } if (state.matches('waitingForSession')) { - return ( - - - Waiting for app... - - ); + return ; } if (state.matches('needsScroll')) { - return 'Please read all disclosures'; + if (isScrollable) { + return 'Scroll to read full request'; + } + return ; } if (state.matches('preparing')) { - return ( - - - Accessing to Keychain data - - ); + return ; } if (state.matches('preparing2')) { - return ( - - - Parsing passport data - - ); + return ; } if (state.matches('preparing3')) { - return ( - - - Preparing for verification - - ); + return ; } if (state.matches('ready')) { return 'Press and hold to verify'; } if (state.matches('verifying')) { - return ( - - - Generating proof - - ); + return ; } return null; };