Show badge for inactive documents (#1487)

* Show badge for inactive documents

* fix

* refactor to use the new flag

* add inactive check to ProveScreen

* lint

* fix for proving button not working

* use new qrHashlogic

* increase bundle size threshold to 46MB

* remove commented out line

* add kyc related changes

---------

Co-authored-by: seshanthS <seshanth@protonmail.com>
This commit is contained in:
Leszek Stachowski
2026-02-12 20:05:47 +01:00
committed by GitHub
parent ec8b8fc419
commit abf01c82c0
16 changed files with 665 additions and 43 deletions

View File

@@ -3,10 +3,11 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type { FC } from 'react';
import React from 'react';
import { Image, StyleSheet } from 'react-native';
import React, { useCallback } from 'react';
import { Dimensions, Image, Pressable, StyleSheet } from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import { Text, XStack, YStack } from 'tamagui';
import { Separator, Text, XStack, YStack } from 'tamagui';
import { useNavigation } from '@react-navigation/native';
import type { AadhaarData } from '@selfxyz/common';
import type { PassportData } from '@selfxyz/common/types/passport';
@@ -16,8 +17,18 @@ import {
isKycDocument,
isMRZDocument,
} from '@selfxyz/common/utils/types';
import { WarningTriangleIcon } from '@selfxyz/euclid/dist/components/icons/WarningTriangleIcon';
import { RoundFlag } from '@selfxyz/mobile-sdk-alpha/components';
import { white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import {
black,
red600,
slate100,
slate300,
slate400,
slate500,
white,
yellow500,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot, plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import CardBackgroundId1 from '@/assets/images/card_background_id1.png';
@@ -28,14 +39,21 @@ import CardBackgroundId5 from '@/assets/images/card_background_id5.png';
import CardBackgroundId6 from '@/assets/images/card_background_id6.png';
import DevCardLogo from '@/assets/images/dev_card_logo.svg';
import DevCardWave from '@/assets/images/dev_card_wave.svg';
import LogoGray from '@/assets/images/logo_gray.svg';
import SelfLogoPending from '@/assets/images/self_logo_pending.svg';
import WaveOverlay from '@/assets/images/wave_overlay.png';
import { getSecurityLevel } from '@/components/homescreen/cardSecurityBadge';
import { cardStyles } from '@/components/homescreen/cardStyles';
import KycIdCard from '@/components/homescreen/KycIdCard';
import { SvgXml } from '@/components/homescreen/SvgXmlWrapper';
import { useCardDimensions } from '@/hooks/useCardDimensions';
import { getBackgroundIndex } from '@/utils/cardBackgroundSelector';
import { getDocumentAttributes } from '@/utils/documentAttributes';
import {
formatDateFromYYMMDD,
getDocumentAttributes,
getNameAndSurname,
} from '@/utils/documentAttributes';
import { registerModalCallbacks } from '@/utils/modalCallbackRegistry';
const CARD_BACKGROUNDS = [
CardBackgroundId1,
@@ -279,6 +297,7 @@ interface IdCardLayoutAttributes {
idDocument: PassportData | AadhaarData | KycData | null;
selected: boolean;
hidden: boolean;
isInactive?: boolean;
}
/**
@@ -293,7 +312,38 @@ const IdCardLayout: FC<IdCardLayoutAttributes> = ({
idDocument,
selected,
hidden,
isInactive = false,
}) => {
const navigation = useNavigation();
const navigateToDocumentOnboarding = useCallback(() => {
switch (idDocument?.documentCategory) {
case 'passport':
case 'id_card':
navigation.navigate('DocumentOnboarding');
break;
case 'aadhaar':
navigation.navigate('AadhaarUpload', { countryCode: 'IND' });
break;
}
}, [idDocument?.documentCategory, navigation]);
const handleInactivePress = useCallback(() => {
const callbackId = registerModalCallbacks({
onButtonPress: navigateToDocumentOnboarding,
onModalDismiss: () => {},
});
navigation.navigate('Modal', {
titleText: 'Your ID needs to be reactivated to continue',
bodyText:
'Make sure that you have your document and recovery method ready.',
buttonText: 'Continue',
secondaryButtonText: 'Not now',
callbackId,
});
}, [navigateToDocumentOnboarding, navigation]);
// Early return if document is null
// Call hooks at the top, before any conditional returns
const {
cardWidth,
@@ -399,7 +449,47 @@ const IdCardLayout: FC<IdCardLayoutAttributes> = ({
const truncatedId = getTruncatedId();
return (
<YStack width="100%" alignItems="center" justifyContent="center">
// Container wrapper to handle shadow space properly
<YStack
width="100%" // Add space for horizontal margins
alignItems="center"
justifyContent="center"
>
{isInactive && (
<Pressable
style={styles.inactiveWarningContainer}
onPress={handleInactivePress}
>
<XStack
backgroundColor={red600}
borderRadius={8}
padding={16}
gap={16}
>
<YStack padding={8} backgroundColor={white} borderRadius={8}>
<WarningTriangleIcon color={yellow500} />
</YStack>
<YStack gap={4}>
<Text
color={white}
fontFamily={dinot}
fontSize={16}
fontWeight="500"
>
Your document is inactive
</Text>
<Text
color={white}
fontFamily={dinot}
fontSize={14}
fontWeight="400"
>
Tap here to recover your ID
</Text>
</YStack>
</XStack>
</Pressable>
)}
<YStack
width={cardWidth}
height={cardHeight}
@@ -566,23 +656,45 @@ const IdCardLayout: FC<IdCardLayoutAttributes> = ({
</Text>
</YStack>
{/* Security Badge */}
<YStack
backgroundColor="rgba(0, 0, 0, 0.5)"
borderRadius={30}
paddingHorizontal={padding * 0.6}
paddingVertical={padding * 0.3}
>
<Text
fontFamily={dinot}
fontSize={fontSize.badge}
fontWeight="500"
color={white}
textTransform="uppercase"
letterSpacing={0.6}
{/* Bottom Right: Badges */}
<YStack alignItems="flex-end" gap={4}>
{isInactive && (
<YStack
backgroundColor={red600}
borderRadius={30}
paddingHorizontal={padding * 0.6}
paddingVertical={padding * 0.3}
>
<Text
fontFamily={dinot}
fontSize={fontSize.badge}
fontWeight="500"
color={white}
textTransform="uppercase"
letterSpacing={0.6}
>
INACTIVE
</Text>
</YStack>
)}
{/* Security Badge */}
<YStack
backgroundColor="rgba(0, 0, 0, 0.5)"
borderRadius={30}
paddingHorizontal={padding * 0.6}
paddingVertical={padding * 0.3}
>
{securityLevel}
</Text>
<Text
fontFamily={dinot}
fontSize={fontSize.badge}
fontWeight="500"
color={white}
textTransform="uppercase"
letterSpacing={0.6}
>
{securityLevel}
</Text>
</YStack>
</YStack>
</XStack>
</YStack>
@@ -605,6 +717,10 @@ const styles = StyleSheet.create({
height: '90%',
opacity: 0.6,
},
inactiveWarningContainer: {
width: '100%',
marginBottom: 16,
},
});
export default IdCardLayout;

View File

@@ -18,6 +18,7 @@ export interface BottomVerifyBarProps {
isReadyToProve: boolean;
isDocumentExpired: boolean;
testID?: string;
hasCheckedForInactiveDocument: boolean;
}
export const BottomVerifyBar: React.FC<BottomVerifyBarProps> = ({
@@ -28,6 +29,7 @@ export const BottomVerifyBar: React.FC<BottomVerifyBarProps> = ({
isReadyToProve,
isDocumentExpired,
testID = 'bottom-verify-bar',
hasCheckedForInactiveDocument,
}) => {
const insets = useSafeAreaInsets();
@@ -46,6 +48,7 @@ export const BottomVerifyBar: React.FC<BottomVerifyBarProps> = ({
isScrollable={isScrollable}
isReadyToProve={isReadyToProve}
isDocumentExpired={isDocumentExpired}
hasCheckedForInactiveDocument={hasCheckedForInactiveDocument}
/>
</View>
);

View File

@@ -52,6 +52,7 @@ import type {
import {
brutforceSignatureAlgorithmDsc,
calculateContentHash,
inferDocumentCategory,
} from '@selfxyz/common/utils';
import { parseCertificateSimple } from '@selfxyz/common/utils/certificate_parsing/parseCertificateSimple';
import type {
@@ -869,6 +870,12 @@ export async function storeDocumentWithDeduplication(
// Store new document using contentHash as service name
await storeDocumentDirectlyToKeychain(contentHash, passportData);
const documentCategory =
passportData.documentCategory ||
inferDocumentCategory(
(passportData as PassportData | AadhaarData).documentType,
);
// Add to catalog
let dataField: string;
if (isMRZDocument(passportData)) {
@@ -886,6 +893,8 @@ export async function storeDocumentWithDeduplication(
data: dataField,
mock: passportData.mock || false,
isRegistered: false,
hasExpirationDate:
documentCategory === 'id_card' || documentCategory === 'passport',
...(isKycDocument(passportData)
? (() => {
try {

View File

@@ -3,7 +3,7 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { ScrollView } from 'react-native';
import { Alert, ScrollView } from 'react-native';
import { YStack } from 'tamagui';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
@@ -13,6 +13,10 @@ import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
import BugIcon from '@/assets/icons/bug_icon.svg';
import ErrorBoundary from '@/components/ErrorBoundary';
import type { RootStackParamList } from '@/navigation';
import {
loadDocumentCatalogDirectlyFromKeychain,
saveDocumentCatalogDirectlyToKeychain,
} from '@/providers/passportDataProvider';
import { ErrorInjectionSelector } from '@/screens/dev/components/ErrorInjectionSelector';
import { LogLevelSelector } from '@/screens/dev/components/LogLevelSelector';
import { ParameterSection } from '@/screens/dev/components/ParameterSection';
@@ -52,6 +56,32 @@ const DevSettingsScreen: React.FC = () => {
handleClearPendingVerificationsPress,
} = useDangerZoneActions();
const handleRemoveExpirationDateFlagPress = () => {
Alert.alert(
'Remove Expiration Date Flag',
'Are you sure you want to remove the expiration date flag for the current (selected) document?.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: async () => {
const catalog = await loadDocumentCatalogDirectlyFromKeychain();
const selectedDocumentId = catalog.selectedDocumentId;
const selectedDocument = catalog.documents.find(
document => document.id === selectedDocumentId,
);
if (selectedDocument) {
delete selectedDocument.hasExpirationDate;
await saveDocumentCatalogDirectlyToKeychain(catalog);
}
},
},
],
);
};
return (
<ErrorBoundary>
<ScrollView showsVerticalScrollIndicator={false}>
@@ -109,6 +139,7 @@ const DevSettingsScreen: React.FC = () => {
onResetBackupState={handleResetBackupStatePress}
onClearBackupEvents={handleClearBackupEventsPress}
onClearPendingKyc={handleClearPendingVerificationsPress}
onRemoveExpirationDateFlag={handleRemoveExpirationDateFlagPress}
/>
</YStack>
</ScrollView>

View File

@@ -23,6 +23,7 @@ interface DangerZoneSectionProps {
onResetBackupState: () => void;
onClearBackupEvents: () => void;
onClearPendingKyc: () => void;
onRemoveExpirationDateFlag: () => void;
}
export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
@@ -32,6 +33,7 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
onResetBackupState,
onClearBackupEvents,
onClearPendingKyc,
onRemoveExpirationDateFlag,
}) => {
const dangerActions = [
{
@@ -64,6 +66,11 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
onPress: onClearPendingKyc,
dangerTheme: true,
},
{
label: 'Remove expiration date flag',
onPress: onRemoveExpirationDateFlag,
dangerTheme: true,
},
];
return (

View File

@@ -59,6 +59,7 @@ import {
checkDocumentExpiration,
getDocumentAttributes,
} from '@/utils/documentAttributes';
import { isDocumentInactive } from '@/utils/documents';
const HomeScreen: React.FC = () => {
const selfClient = useSelfClient();
@@ -80,6 +81,9 @@ const HomeScreen: React.FC = () => {
>({});
const [loading, setLoading] = useState(true);
const hasIncrementedOnFocus = useRef(false);
const [isSelectedDocumentInactive, setIsSelectedDocumentInactive] = useState<
boolean | null
>(null);
const { pendingVerifications, removeExpiredVerifications } =
usePendingKycStore();
@@ -126,12 +130,28 @@ const HomeScreen: React.FC = () => {
const loadDocuments = useCallback(async () => {
setLoading(true);
try {
const catalog = await loadDocumentCatalog();
const docs = await getAllDocuments();
setDocumentCatalog(catalog);
setAllDocuments(docs);
if (catalog.selectedDocumentId) {
const documentData = docs[catalog.selectedDocumentId];
if (documentData) {
try {
setIsSelectedDocumentInactive(
isDocumentInactive(documentData.metadata),
);
} catch (error) {
// we don't want to block the home screen from loading
console.warn('Failed to check if document is inactive:', error);
}
}
}
} catch (error) {
console.warn('Failed to load documents:', error);
}
@@ -307,6 +327,11 @@ const HomeScreen: React.FC = () => {
>
<IdCardLayout
idDocument={documentData.data}
isInactive={
isSelected &&
isSelectedDocumentInactive === true &&
!metadata.mock
}
selected={isSelected}
hidden={true}
/>

View File

@@ -25,6 +25,7 @@ import {
} from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { DocumentMetadata } from '@selfxyz/common';
import { isMRZDocument } from '@selfxyz/common';
import { loadSelectedDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { ProofEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
@@ -51,10 +52,12 @@ import {
} from '@/services/points';
import { useProofHistoryStore } from '@/stores/proofHistoryStore';
import { ProofStatus } from '@/stores/proofTypes';
import { registerModalCallbacks } from '@/utils';
import {
checkDocumentExpiration,
getDocumentAttributes,
} from '@/utils/documentAttributes';
import { isDocumentInactive } from '@/utils/documents';
import { getDocumentTypeName } from '@/utils/documentUtils';
const ProveScreen: React.FC = () => {
@@ -85,6 +88,9 @@ const ProveScreen: React.FC = () => {
const scrollViewRef = useRef<ScrollViewType>(null);
const hasInitializedScrollStateRef = useRef(false);
const [hasCheckedForInactiveDocument, setHasCheckedForInactiveDocument] =
useState<boolean>(false);
const isContentShorterThanScrollView = useMemo(
() => scrollViewContentHeight <= scrollViewHeight + 50,
[scrollViewContentHeight, scrollViewHeight],
@@ -114,8 +120,70 @@ const ProveScreen: React.FC = () => {
const { addProofHistory } = useProofHistoryStore();
const { loadDocumentCatalog } = usePassport();
const navigateToDocumentOnboarding = useCallback(
(documentMetadata: DocumentMetadata) => {
switch (documentMetadata.documentCategory) {
case 'passport':
case 'id_card':
navigate('DocumentOnboarding');
break;
case 'aadhaar':
navigate('AadhaarUpload', { countryCode: 'IND' });
break;
}
},
[navigate],
);
useEffect(() => {
// Don't check twice
if (hasCheckedForInactiveDocument) {
return;
}
const checkForInactiveDocument = async () => {
const catalog = await loadDocumentCatalog();
const selectedDocumentId = catalog.selectedDocumentId;
for (const documentMetadata of catalog.documents) {
if (
documentMetadata.id === selectedDocumentId &&
isDocumentInactive(documentMetadata)
) {
const callbackId = registerModalCallbacks({
onButtonPress: () => navigateToDocumentOnboarding(documentMetadata),
onModalDismiss: () => navigate('Home' as never),
});
navigate('Modal', {
titleText: 'Your ID needs to be reactivated to continue',
bodyText:
'Make sure that you have your document and recovery method ready.',
buttonText: 'Continue',
secondaryButtonText: 'Not now',
callbackId,
});
return;
}
}
setHasCheckedForInactiveDocument(true);
};
checkForInactiveDocument();
}, [
loadDocumentCatalog,
navigateToDocumentOnboarding,
navigate,
hasCheckedForInactiveDocument,
]);
useEffect(() => {
if (!hasCheckedForInactiveDocument) {
return;
}
const addHistory = async () => {
if (provingStore.uuid && selectedApp) {
const catalog = await loadDocumentCatalog();
@@ -137,9 +205,19 @@ const ProveScreen: React.FC = () => {
}
};
addHistory();
}, [addProofHistory, loadDocumentCatalog, provingStore.uuid, selectedApp]);
}, [
addProofHistory,
provingStore.uuid,
selectedApp,
loadDocumentCatalog,
hasCheckedForInactiveDocument,
]);
useEffect(() => {
if (!hasCheckedForInactiveDocument) {
return;
}
// Wait for actual measurements before determining initial scroll state
// Both start at 0, causing false-positive on first render
const hasMeasurements = scrollViewContentHeight > 0 && scrollViewHeight > 0;
@@ -161,10 +239,11 @@ const ProveScreen: React.FC = () => {
isContentShorterThanScrollView,
scrollViewContentHeight,
scrollViewHeight,
hasCheckedForInactiveDocument,
]);
useEffect(() => {
if (!isFocused || !selectedApp) {
if (!isFocused || !selectedApp || !hasCheckedForInactiveDocument) {
return;
}
@@ -229,12 +308,21 @@ const ProveScreen: React.FC = () => {
//removed provingStore from dependencies because it causes infinite re-render on longpressing the button
//as it sets provingStore.setUserConfirmed()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedApp?.sessionId, isFocused, selfClient]);
}, [
selectedApp?.sessionId,
isFocused,
selfClient,
hasCheckedForInactiveDocument,
]);
// Enhance selfApp with user's points address if not already set
useEffect(() => {
console.log('useEffect selectedApp', selectedApp);
if (!selectedApp || selectedApp.selfDefinedData) {
if (
!selectedApp ||
selectedApp.selfDefinedData ||
!hasCheckedForInactiveDocument
) {
return;
}
@@ -277,11 +365,11 @@ const ProveScreen: React.FC = () => {
};
enhanceApp();
}, [selectedApp, selfClient]);
}, [selectedApp, selfClient, hasCheckedForInactiveDocument]);
function onVerify() {
provingStore.setUserConfirmed(selfClient);
buttonTap();
provingStore.setUserConfirmed(selfClient);
trackEvent(ProofEvents.PROOF_VERIFY_CONFIRMATION_ACCEPTED, {
appName: selectedApp?.appName,
sessionId: provingStore.uuid,
@@ -388,6 +476,7 @@ const ProveScreen: React.FC = () => {
isReadyToProve={isReadyToProve}
isDocumentExpired={isDocumentExpired}
testID="prove-screen-verify-bar"
hasCheckedForInactiveDocument={hasCheckedForInactiveDocument}
/>
{formattedUserId && selectedApp?.userId && (

View File

@@ -0,0 +1,22 @@
// 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 { DocumentMetadata } from '@selfxyz/common';
export const isDocumentInactive = (metadata: DocumentMetadata): boolean => {
if (
metadata.documentCategory === 'id_card' ||
metadata.documentCategory === 'passport' ||
metadata.documentCategory === 'kyc'
) {
return false;
}
//for aadhaar migration
if (metadata.hasExpirationDate === undefined) {
return true;
}
return false;
};