[INJIMOB-3367] add support for sd-jwt vc parsing and rendering (#2042)

* [INJIMOB-3367] add support for sd-jwt vc parsing and rendering

Signed-off-by: Abhishek Paul <paul.apaul.abhishek.ap@gmail.com>

* [INJIMOB-3367] add sha384 and sha 512 support for sd jwt parsing

Signed-off-by: Abhishek Paul <paul.apaul.abhishek.ap@gmail.com>

* [INJIMOB-3367] fix bottom sectionview fields rendering for sdjwt

Signed-off-by: Abhishek Paul <paul.apaul.abhishek.ap@gmail.com>

* [INJIMOB-3367] remove logs

Signed-off-by: Abhishek Paul <paul.apaul.abhishek.ap@gmail.com>

* [INJIMOB-3367] add dc+sd-jwt support

Signed-off-by: Abhishek Paul <paul.apaul.abhishek.ap@gmail.com>

---------

Signed-off-by: Abhishek Paul <paul.apaul.abhishek.ap@gmail.com>
This commit is contained in:
abhip2565
2025-08-13 08:06:35 +05:30
committed by GitHub
parent b0168c31df
commit f2c6211b95
21 changed files with 884 additions and 124 deletions

View File

@@ -0,0 +1,186 @@
import React from 'react';
import { Overlay } from 'react-native-elements';
import { Text } from '../../../components/ui';
import { Icon } from 'react-native-elements';
import { Theme } from '../../../components/ui/styleUtils';
import { FlatList, View } from 'react-native';
import { Dimensions } from 'react-native';
import { useTranslation } from 'react-i18next';
const maxHeight = Dimensions.get('window').height / 1.5;
export interface ShareableInfoModalProps {
isVisible: boolean;
onDismiss: () => void;
disclosedPaths: string[];
}
type FieldNode = {
label: string;
indentLevel: number;
isArrayItem: boolean;
isDisclosed: boolean;
};
const parsePathParts = (path: string): { key: string; isArrayItem: boolean }[] => {
const parts = path.split('.');
return parts.map(part => {
const match = part.match(/^(.+?)(\[\d+\])?$/);
return {
key: match?.[1] || part,
isArrayItem: !!match?.[2],
};
});
};
const groupDisclosedFieldsWithArrayInfo = (paths: string[]): FieldNode[] => {
const tree: any = {};
paths.forEach(path => {
const parts = parsePathParts(path);
let current = tree;
parts.forEach(({ key, isArrayItem }, idx) => {
const isLeaf = idx === parts.length - 1;
if (!current[key]) {
current[key] = {
_children: {},
_isArrayItem: isArrayItem,
_isDisclosed: false,
};
}
// If this is the last part of the path, mark it as disclosed
if (isLeaf) {
current[key]._isDisclosed = true;
}
// Carry forward array info
current[key]._isArrayItem = current[key]._isArrayItem || isArrayItem;
current = current[key]._children;
});
});
const flatten = (node: any, level = 0): FieldNode[] => {
return Object.entries(node).flatMap(([key, value]: any) => {
const label = key
.replace(/([A-Z])/g, ' $1')
.replace(/_/g, ' ')
.replace(/^./, s => s.toUpperCase());
const entry = [{
label,
indentLevel: level,
isArrayItem: value._isArrayItem,
isDisclosed: value._isDisclosed,
}];
const children = flatten(value._children, level + 1);
return [...entry, ...children];
});
};
return flatten(tree);
};
export const ShareableInfoModal: React.FC<ShareableInfoModalProps> = ({
isVisible,
onDismiss,
disclosedPaths,
}) => {
const { t } = useTranslation("VcDetails");
const disclosedFields = groupDisclosedFieldsWithArrayInfo(disclosedPaths);
return (
<Overlay
isVisible={isVisible}
onBackdropPress={onDismiss}
overlayStyle={{
...Theme.DisclosureOverlayStyles.overlay,
maxHeight
}}
>
<View style={{ maxHeight }}>
<View
style={Theme.DisclosureOverlayStyles.outerView}
>
<View style={{ flex: 1 }}>
<Text style={Theme.DisclosureOverlayStyles.titleText}>
{t('disclosedFieldsTitle')}
</Text>
<Text style={Theme.DisclosureOverlayStyles.titleDescription}>
{t('disclosedFieldsDescription')}
</Text>
</View>
<Icon
name="close"
type="material"
color={Theme.Colors.blackIcon}
size={20}
onPress={onDismiss}
containerStyle={{ position: 'absolute', right: 16, top: 16 }}
/>
</View>
<FlatList
data={disclosedFields}
keyExtractor={(item, index) => item.label + index}
style={{ maxHeight: maxHeight }}
contentContainerStyle={{
paddingVertical: 10,
paddingBottom: 24,
}}
renderItem={({ item }) => (
<View
style={{
...Theme.DisclosureOverlayStyles.listView,
borderColor: item.isDisclosed ? Theme.Colors.lightGreyBackgroundColor : 'white',
backgroundColor: item.isDisclosed ? Theme.Colors.DetailedViewBackground : 'white',
}}
>
<Text
style={{
fontSize: 15,
paddingLeft: item.indentLevel * 12,
}}
>
{item.isArrayItem ? '• ' : item.indentLevel > 0 ? '└─ ' : ''}
{item.label}
</Text>
</View>
)}
showsVerticalScrollIndicator={true}
bounces={true}
/>
<View
style={Theme.DisclosureOverlayStyles.noteView}
>
<Icon
name="info"
type="feather"
color="#973C00"
size={20}
containerStyle={{ marginRight: 8, marginBottom: 4 }}
/>
<View style={{ flex: 1 }}>
<Text
style={Theme.DisclosureOverlayStyles.noteTitleText}
>
{t('disclosureNoteTitle')}
</Text>
<Text
style={Theme.DisclosureOverlayStyles.noteDescriptionText}
>
{t('disclosureNote')}
</Text>
</View>
</View>
</View>
</Overlay>
);
};

View File

@@ -94,7 +94,6 @@ export const VCCardView: React.FC<VCItemProps> = ({
if (!isVCLoaded(controller.credential) || !wellknown || !vc) {
return <VCCardSkeleton />;
}
const CardViewContent = () => (
<VCCardViewContent
vcMetadata={vcMetadata}

View File

@@ -1,6 +1,8 @@
import React from 'react';
import {useTranslation} from 'react-i18next';
import {Image, ImageBackground, ImageBackgroundProps, View} from 'react-native';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import Icon from 'react-native-vector-icons/FontAwesome';
import Feather from 'react-native-vector-icons/Feather';
import { Image, ImageBackground, ImageBackgroundProps, TouchableOpacity, View } from 'react-native';
import {
Credential,
CredentialWrapper,
@@ -8,25 +10,26 @@ import {
VerifiableCredentialData,
WalletBindingResponse,
} from '../../../machines/VerifiableCredential/VCMetaMachine/vc';
import {Button, Column, Row, Text} from '../../ui';
import {Theme} from '../../ui/styleUtils';
import {QrCodeOverlay} from '../../QrCodeOverlay';
import {SvgImage} from '../../ui/svg';
import {isActivationNeeded} from '../../../shared/openId4VCI/Utils';
import { Button, Column, Row, Text } from '../../ui';
import { Theme } from '../../ui/styleUtils';
import { QrCodeOverlay } from '../../QrCodeOverlay';
import { SvgImage } from '../../ui/svg';
import { isActivationNeeded } from '../../../shared/openId4VCI/Utils';
import {
BOTTOM_SECTION_FIELDS_WITH_DETAILED_ADDRESS_FIELDS,
DETAIL_VIEW_BOTTOM_SECTION_FIELDS,
Display,
fieldItemIterator,
} from '../common/VCUtils';
import {VCFormat} from '../../../shared/VCFormat';
import {VCItemField} from '../common/VCItemField';
import { VCFormat } from '../../../shared/VCFormat';
import testIDProps from '../../../shared/commonUtil';
import { ShareableInfoModal } from './ShareableInfoModal';
const getProfileImage = (face: any) => {
if (face) {
return (
<Image source={{uri: face}} style={Theme.Styles.detailedViewImage} />
<Image source={{ uri: face }} style={Theme.Styles.detailedViewImage} />
);
}
return <></>;
@@ -35,7 +38,7 @@ const getProfileImage = (face: any) => {
export const VCDetailView: React.FC<VCItemDetailsProps> = (
props: VCItemDetailsProps,
) => {
const {t} = useTranslation('VcDetails');
const { t } = useTranslation('VcDetails');
const logo = props.verifiableCredentialData.issuerLogo;
const face = props.verifiableCredentialData.face;
const verifiableCredential = props.credential;
@@ -50,7 +53,7 @@ export const VCDetailView: React.FC<VCItemDetailsProps> = (
} else if (
props.verifiableCredentialData.vcMetadata.format === VCFormat.mso_mdoc
) {
const namespaces = verifiableCredential['issuerSigned']?.['nameSpaces'] ?? verifiableCredential['nameSpaces']??{};
const namespaces = verifiableCredential['issuerSigned']?.['nameSpaces'] ?? verifiableCredential['nameSpaces'] ?? {};
Object.keys(namespaces).forEach(namespace => {
(namespaces[namespace] as Array<Object>).forEach(element => {
availableFieldNames.push(
@@ -59,6 +62,11 @@ export const VCDetailView: React.FC<VCItemDetailsProps> = (
});
});
}
else if (
props.verifiableCredentialData.vcMetadata.format === VCFormat.vc_sd_jwt || props.verifiableCredentialData.vcMetadata.format === VCFormat.dc_sd_jwt
) {
availableFieldNames = Object.keys(verifiableCredential?.fullResolvedPayload);
}
for (const fieldName of availableFieldNames) {
if (
BOTTOM_SECTION_FIELDS_WITH_DETAILED_ADDRESS_FIELDS.includes(fieldName)
@@ -69,7 +77,7 @@ export const VCDetailView: React.FC<VCItemDetailsProps> = (
return false;
};
const [shareModalVisible, setShareModalVisible] = useState(false);
return (
<>
<Column scroll>
@@ -117,13 +125,14 @@ export const VCDetailView: React.FC<VCItemDetailsProps> = (
<Column
align="space-evenly"
margin={'0 0 0 24'}
style={{flex: 1}}>
style={{ flex: 1 }}>
{fieldItemIterator(
props.fields,
props.wellknownFieldsFlag,
props.fields,
props.wellknownFieldsFlag,
verifiableCredential,
props.wellknown,
wellknownDisplayProperty,
false,
props,
)}
</Column>
@@ -146,9 +155,11 @@ export const VCDetailView: React.FC<VCItemDetailsProps> = (
verifiableCredential,
props.wellknown,
wellknownDisplayProperty,
true,
props,
)}
</Column>
{(props.credential.disclosedKeys != null) && (<DisclosureInfoNote />)}
</>
</ImageBackground>
</Column>
@@ -194,7 +205,6 @@ export const VCDetailView: React.FC<VCItemDetailsProps> = (
</Text>
</Column>
</Row>
<Button
testID="enableVerification"
title={t('enableVerification')}
@@ -235,10 +245,63 @@ export const VCDetailView: React.FC<VCItemDetailsProps> = (
))}
</View>
)}
{
(props.credential.disclosedKeys != null) && (<View
style={{
padding: 16,
backgroundColor: Theme.Colors.DetailedViewBackground,
borderTopWidth: 1,
borderTopColor: Theme.Colors.lightGreyBackgroundColor,
alignItems: 'center',
}}>
<TouchableOpacity
onPress={() => setShareModalVisible(true)}
testID="viewShareableInfoLink"
style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Feather name="eye" size={20} color={"#007AFF"} />
<Text
style={{
color: '#007AFF',
fontSize: 16,
fontFamily: 'Inter_500Medium',
}}>
{t('View Shareable Information')}
</Text>
</TouchableOpacity>
</View>)}
<ShareableInfoModal
isVisible={shareModalVisible}
onDismiss={() => setShareModalVisible(false)}
disclosedPaths={Array.from(props.credential.disclosedKeys ?? {}) || []}
/>
</>
);
};
export const DisclosureInfoNote = () => {
const { t } = useTranslation('VcDetails');
return (
<View
style={Theme.DisclosureInfo.view}>
<Row align="flex-start">
<Icon
name="share-square-o"
size={18}
color={Theme.Colors.DetailsLabel}
style={{ marginTop: 2, marginRight: 8 }}
/>
<Text
style={Theme.DisclosureInfo.text}>
{t('disclosureInfoNote')}
</Text>
</Row>
</View>
);
};
export interface VCItemDetailsProps {
fields: any[];
wellknown: any;

View File

@@ -5,15 +5,18 @@ import {Theme} from '../../ui/styleUtils';
import React from 'react';
import {SvgImage} from '../../ui/svg';
import {useTranslation} from 'react-i18next';
import Icon from 'react-native-vector-icons/FontAwesome';
export const VCItemFieldName = ({
fieldName,
testID,
fieldNameColor: textColor = Theme.Colors.DetailsLabel,
isDisclosed = false,
}: {
fieldName: string;
testID: string;
fieldNameColor?: string;
isDisclosed?: boolean;
}) => {
const {t} = useTranslation('ViewVcModal');
return (
@@ -79,6 +82,9 @@ export const VCItemFieldName = ({
}
/>
)}
{isDisclosed && (
<Icon name="share-square-o" size={10} color="#666" style={{marginLeft:5, marginTop:3}} />
)}
</Row>
);
};
@@ -119,4 +125,5 @@ interface VCItemFieldProps {
testID: string;
fieldNameColor?: string;
fieldValueColor?: string;
isDisclosed?: boolean;
}

View File

@@ -3,6 +3,9 @@ import {VerifiableCredential} from '../../../machines/VerifiableCredential/VCMet
import {VCFormat} from '../../../shared/VCFormat';
import {getVerifiableCredential} from '../../../machines/VerifiableCredential/VCItemMachine/VCItemSelectors';
import {parseJSON} from '../../../shared/Utils';
import base64url from 'base64url';
import jwtDecode from 'jwt-decode';
import { sha256,sha384 ,sha512} from '@noble/hashes/sha2';
const {RNPixelpassModule} = NativeModules;
@@ -21,6 +24,131 @@ export class VCProcessor {
);
return parseJSON(decodedString);
}
if(vcFormat === VCFormat.vc_sd_jwt || vcFormat === VCFormat.dc_sd_jwt) {
const { fullResolvedPayload, disclosedKeys, publicKeys } =
reconstructSdJwtFromCompact(vcData.credential.toString());
return {fullResolvedPayload,disclosedKeys,publicKeys};
}
return getVerifiableCredential(vcData);
}
}
/*
Transforms SD-JWT into a fully reconstructable JSON object
Input: full SD-JWT string (with disclosures appended)
Output:
- fullResolvedPayload: resolved JSON with all disclosed claims
- disclosedKeys: Set of keys that were disclosed via disclosures (as full JSON paths)
- publicKeys: Set of keys that were present in JWT payload directly (non-selectively-disclosable)
*/
function hashDigest(alg: string, input: string): Uint8Array {
switch (alg) {
case 'sha-256':
return sha256(input);
case 'sha-384':
return sha384(input);
case 'sha-512':
return sha512(input);
default:
throw new Error(`Unsupported _sd_alg: ${alg}`);
}
}
export function reconstructSdJwtFromCompact(
sdJwtCompact: string,
): {
fullResolvedPayload: Record<string, any>;
disclosedKeys: Set<string>;
publicKeys: Set<string>;
} {
const sdJwtPublicKeys = ["iss", "sub", "aud", "exp", "nbf", "iat", "jti"];
const disclosedKeys = new Set<string>();
const publicKeys = new Set<string>();
const digestToDisclosure: Record<string, any[]> = {};
// Split SD-JWT into parts: [jwt, disclosure1, disclosure2, ...]
const parts = sdJwtCompact.trim().split('~');
const jwt = parts[0];
const disclosures = parts.slice(1);
const payload: any = jwtDecode(jwt);
const sdAlg = payload._sd_alg || 'sha-256';
// Parse disclosures
for (const disclosureB64 of disclosures) {
if(disclosureB64.length > 0) {
const decodedB64 = disclosureB64.replace(/-/g, '+').replace(/_/g, '/');
const decoded = JSON.parse(Buffer.from(decodedB64, 'base64').toString('utf-8'));
const digestInput = disclosureB64
const digest = base64url(Buffer.from(hashDigest(sdAlg,digestInput)));
digestToDisclosure[digest] = decoded;
}
}
//Parse the JWT payload
function resolveDisclosures(value: any, path: string = ''): any {
if (Array.isArray(value)) {
return value.flatMap((item, index) => {
const currentPath = `${path}[${index}]`;
if (
typeof item === 'object' &&
item !== null &&
Object.keys(item).length === 1 &&
'...' in item
) {
const digest = item['...'];
const disclosure = digestToDisclosure[digest];
if (!disclosure || disclosure.length !== 2) {
return [];
}
disclosedKeys.add(currentPath);
return [resolveDisclosures(disclosure[1], currentPath)];
} else {
return [resolveDisclosures(item, currentPath)];
}
});
}
if (typeof value === 'object' && value !== null) {
let result: Record<string, any> = {};
const sdDigests: string[] = value._sd || [];
for (const digest of sdDigests) {
const disclosure = digestToDisclosure[digest];
if (!disclosure || disclosure.length !== 3) {
continue;
}
const [_, claimName, claimValue] = disclosure;
if (claimName === '_sd' || claimName === '...') continue;
if (claimName in value) throw new Error('Overwriting existing key');
const fullPath = path ? `${path}.${claimName}` : claimName;
disclosedKeys.add(fullPath);
result[claimName] = resolveDisclosures(claimValue, fullPath);
}
for (const [k, v] of Object.entries(value)) {
if (k === '_sd') continue;
const fullPath = path ? `${path}.${k}` : k;
result[k] = resolveDisclosures(v, fullPath);
}
return result;
}
return value;
}
// Track public (non-selectively-disclosable) claims
for (const key of Object.keys(payload)) {
if (key !== '_sd' && key !== '_sd_alg' && sdJwtPublicKeys.includes(key)) {
publicKeys.add(key);
}
}
const fullResolvedPayload = resolveDisclosures(payload);
delete fullResolvedPayload['_sd_alg'];
return { fullResolvedPayload, disclosedKeys, publicKeys };
}

View File

@@ -5,24 +5,24 @@ import {
IssuerWellknownResponse,
VerifiableCredential,
} from '../../../machines/VerifiableCredential/VCMetaMachine/vc';
import i18n, {getLocalizedField} from '../../../i18n';
import {Row} from '../../ui';
import {Text} from 'react-native';
import {VCItemField} from './VCItemField';
import i18n, { getLocalizedField } from '../../../i18n';
import { Row } from '../../ui';
import { Text } from 'react-native';
import { VCItemField } from './VCItemField';
import React from 'react';
import {Theme} from '../../ui/styleUtils';
import {CREDENTIAL_REGISTRY_EDIT} from 'react-native-dotenv';
import {VCVerification} from '../../VCVerification';
import {MIMOTO_BASE_URL} from '../../../shared/constants';
import {VCItemDetailsProps} from '../Views/VCDetailView';
import { Theme } from '../../ui/styleUtils';
import { CREDENTIAL_REGISTRY_EDIT } from 'react-native-dotenv';
import { VCVerification } from '../../VCVerification';
import { MIMOTO_BASE_URL } from '../../../shared/constants';
import { VCItemDetailsProps } from '../Views/VCDetailView';
import {
getDisplayObjectForCurrentLanguage,
getMatchingCredentialIssuerMetadata,
} from '../../../shared/openId4VCI/Utils';
import {VCFormat} from '../../../shared/VCFormat';
import {displayType} from '../../../machines/Issuers/IssuersMachine';
import {Image} from 'react-native-elements/dist/image/Image';
import { VCFormat } from '../../../shared/VCFormat';
import { displayType } from '../../../machines/Issuers/IssuersMachine';
import { Image } from 'react-native-elements/dist/image/Image';
import Icon from 'react-native-vector-icons/FontAwesome';
export const CARD_VIEW_DEFAULT_FIELDS = ['fullName'];
export const DETAIL_VIEW_DEFAULT_FIELDS = [
'fullName',
@@ -110,6 +110,49 @@ export const getFieldValue = (
);
}
}
else if (format === VCFormat.vc_sd_jwt || format === VCFormat.dc_sd_jwt) {
const fieldParts = field.split('.');
let value: any = verifiableCredential?.fullResolvedPayload;
for (let i = 0; i < fieldParts.length; i++) {
const part = fieldParts[i];
if (!value) break;
value = value[part];
// If we hit an array and we still have more path to go...
if (Array.isArray(value) && i < fieldParts.length - 1) {
const remainingPath = fieldParts.slice(i + 1);
value = value.map(item => {
let inner = item;
for (const p of remainingPath) {
inner = inner?.[p];
}
return inner;
});
break;
}
}
if (Array.isArray(value) && typeof value[0] !== 'object') {
return value.join(', ');
}
if (Array.isArray(value) && typeof value[0] === 'object') {
if ('language' in value[0] &&
'value' in value[0]) {
return getLocalizedField(value)
}
else
return null;
}
if (typeof value === 'object' && !Array.isArray(value)) {
return null;
}
return getLocalizedField(value?.toString());
}
}
}
};
@@ -155,6 +198,29 @@ export const getFieldName = (
}
}
}
else if (format === VCFormat.vc_sd_jwt || format === VCFormat.dc_sd_jwt) {
const pathParts = field.split('.');
let currentObj = wellknown.claims;
for (const part of pathParts) {
if (!currentObj || typeof currentObj !== 'object') break;
currentObj = currentObj[part];
}
if (
currentObj &&
typeof currentObj === 'object' &&
currentObj.display &&
Array.isArray(currentObj.display)
) {
const newFieldObj = currentObj.display.map((obj: any) => ({
language: obj.locale,
value: obj.name,
}));
return getLocalizedField(newFieldObj);
}
return formatKeyLabel(pathParts[pathParts.length - 1]);
}
}
return formatKeyLabel(field);
};
@@ -163,17 +229,17 @@ const ID = ['id'];
const IMAGE_KEYS = ['face', 'photo', 'picture', 'portrait', 'image'];
const EXCLUDED_FIELDS_FOR_RENDERING = [...ID, ...IMAGE_KEYS];
const EXCLUDED_FIELDS_FOR_RENDERING = [...ID, ...IMAGE_KEYS, 'cnf'];
const shouldExcludeField = (field: string): boolean => {
const normalized = field.includes('~')
? field.split('~')[1]
: field.includes('.') || field.includes('[')
? field
? field
.split('.')
.pop()
?.replace(/\[\d+\]/g, '') ?? field
: field;
: field;
return EXCLUDED_FIELDS_FOR_RENDERING.includes(normalized);
};
@@ -236,14 +302,16 @@ const renderFieldRecursively = (
fieldValueColor: string,
parentKey = '',
depth = 0,
renderedFields: Set<string>,
disclosedKeys: Set<string> = new Set(),
): JSX.Element[] => {
const fullKey = parentKey ? `${parentKey}.${key}` : key;
const shortKey =
let shortKey =
fullKey
.split('.')
.pop()
?.replace(/\[\d+\]/g, '') ?? key;
if (renderedFields.has(fullKey)) return [];
if (shouldExcludeField(shortKey)) return [];
if (value === null || value === undefined) return [];
@@ -251,27 +319,47 @@ const renderFieldRecursively = (
// Handle arrays
if (Array.isArray(value)) {
const label = formatKeyLabel(key);
return value.flatMap((item, index) => [
<Text
key={`section-${fullKey}-${index}`}
style={{
paddingLeft: depth * 12,
fontWeight: '600',
marginTop: 8,
marginBottom: 4,
color: fieldNameColor,
}}>
{label} {value.length > 1 ? index + 1 : ''}
</Text>,
...renderFieldRecursively(
`${key}[${index}]`,
item,
fieldNameColor,
fieldValueColor,
parentKey,
depth + 1,
),
]);
const arrayFullKey = fullKey;
const isArrayDisclosed = disclosedKeys.has(arrayFullKey);
return value.flatMap((item, index) => {
const itemKey = `${key}[${index}]`;
const itemFullKey = parentKey ? `${parentKey}.${itemKey}` : itemKey;
const isItemDisclosed = disclosedKeys.has(itemFullKey);
const showDisclosureIcon = isArrayDisclosed || isItemDisclosed;
return [
<Row
key={`section-${itemFullKey}`}
align="flex-start"
style={{
marginTop: 8,
marginBottom: 4,
}}>
<Text
style={{
paddingLeft: depth * 12,
fontWeight: '600',
color: fieldNameColor,
}}>
{label} {value.length > 1 ? index + 1 : ''}
</Text>
{showDisclosureIcon && (
<Icon name="share-square-o" size={14} color="#666" style={{marginLeft:2}} />
)}
</Row>,
...renderFieldRecursively(
itemKey,
item,
fieldNameColor,
fieldValueColor,
parentKey,
depth + 1,
renderedFields,
disclosedKeys
),
];
});
}
// Handle objects
@@ -284,6 +372,8 @@ const renderFieldRecursively = (
fieldValueColor,
fullKey,
depth + 1,
renderedFields,
disclosedKeys
),
);
}
@@ -295,8 +385,8 @@ const renderFieldRecursively = (
if (typeof value === 'string' && value.startsWith('data:image')) {
displayValue = (
<Image
source={{uri: value}}
style={{width: 100, height: 100, borderRadius: 8}}
source={{ uri: value }}
style={{ width: 100, height: 100, borderRadius: 8 }}
resizeMode="contain"
/>
);
@@ -306,20 +396,47 @@ const renderFieldRecursively = (
) {
displayValue = (
<Image
source={{uri: value}}
style={{width: 100, height: 100, borderRadius: 8}}
source={{ uri: value }}
style={{ width: 100, height: 100, borderRadius: 8 }}
resizeMode="contain"
/>
);
} else if (
typeof value === 'number' &&
['iat', 'nbf', 'exp'].includes(shortKey.toLowerCase())
) {
displayValue = new Date(value * 1000).toLocaleString();
} else if (
typeof value === 'string' &&
/^\d+$/.test(value) &&
['iat', 'nbf', 'exp'].includes(shortKey.toLowerCase())
) {
const timestamp = parseInt(value, 10);
displayValue = new Date(timestamp * 1000).toLocaleString();
} else if (/^\d{4}-\d{2}-\d{2}T/.test(displayValue)) {
const date = new Date(displayValue);
displayValue = date.toLocaleString();
} else if (displayValue.length > 100) {
}
else if (displayValue.length > 100) {
displayValue = displayValue.slice(0, 60) + '...';
}
const label = formatKeyLabel(shortKey);
const publicKeyLabelMap: Record<string, string> = {
iss: 'Issuer',
sub: 'Subject',
aud: 'Audience',
exp: 'Expires At',
nbf: 'Not Before',
iat: 'Issued At',
jti: 'JWT ID',
vct: 'Verifiable Credential Type',
};
if (shortKey in publicKeyLabelMap) {
shortKey = publicKeyLabelMap[shortKey];
}
const label = formatKeyLabel(shortKey);
const isDisclosed = disclosedKeys.has(fullKey);
return [
<Row
key={`extra-${fullKey}`}
@@ -337,6 +454,7 @@ const renderFieldRecursively = (
fieldNameColor={fieldNameColor}
fieldValueColor={fieldValueColor}
testID={`extra-${fullKey}`}
isDisclosed={isDisclosed}
/>
</Row>,
];
@@ -348,11 +466,12 @@ export const fieldItemIterator = (
verifiableCredential: VerifiableCredential | Credential,
wellknown: any,
display: Display,
isBottomSectionFields: boolean,
props: VCItemDetailsProps,
): JSX.Element[] => {
const fieldNameColor = display.getTextColor(Theme.Colors.DetailsLabel);
const fieldValueColor = display.getTextColor(Theme.Colors.Details);
const disclosedKeys = verifiableCredential.disclosedKeys || new Set<string>();
const renderedFields = new Set<string>();
const renderedMainFields = fields.map(field => {
@@ -369,6 +488,9 @@ export const fieldItemIterator = (
display,
props.verifiableCredentialData.vcMetadata.format,
);
if (fieldValue == null) {
return null;
}
renderedFields.add(field);
if (
@@ -379,10 +501,11 @@ export const fieldItemIterator = (
return null;
}
const isDisclosed = disclosedKeys.has(field);
return (
<Row
key={field}
style={{flexDirection: 'row', flex: 1}}
style={{ flexDirection: 'row', flex: 1 }}
align="space-between"
margin="0 8 15 0">
<VCItemField
@@ -392,24 +515,24 @@ export const fieldItemIterator = (
fieldNameColor={fieldNameColor}
fieldValueColor={fieldValueColor}
testID={field}
isDisclosed={isDisclosed}
/>
</Row>
);
});
let renderedExtraFields: JSX.Element[] = [];
if (!wellknownFieldsFlag) {
DETAIL_VIEW_BOTTOM_SECTION_FIELDS.forEach(item => renderedFields.add(item));
if (!wellknownFieldsFlag || verifiableCredential.fullResolvedPayload && !isBottomSectionFields) {
const renderedAll: JSX.Element[] = [];
// Extra fields from credentialSubject
const credentialSubjectFields =
(verifiableCredential.credentialSubject as Record<string, any>) || {};
(verifiableCredential.credentialSubject as Record<string, any>) || verifiableCredential.fullResolvedPayload || {};
const renderedSubjectFields = Object.entries(credentialSubjectFields)
.filter(([key]) => !renderedFields.has(key))
.flatMap(([key, value]) =>
renderFieldRecursively(key, value, fieldNameColor, fieldValueColor),
renderFieldRecursively(key, value, fieldNameColor, fieldValueColor, '', 0, renderedFields, disclosedKeys),
);
renderedAll.push(...renderedSubjectFields);
@@ -442,6 +565,8 @@ export const fieldItemIterator = (
fieldValueColor,
namespace,
1,
renderedFields,
disclosedKeys
),
),
];
@@ -521,8 +646,8 @@ export const getCredentialTypeFromWellKnown = (
export class Display {
private readonly textColor: string | undefined = undefined;
private readonly backgroundColor: {backgroundColor: string};
private readonly backgroundImage: {uri: string} | undefined = undefined;
private readonly backgroundColor: { backgroundColor: string };
private readonly backgroundImage: { uri: string } | undefined = undefined;
private defaultBackgroundColor = Theme.Colors.whiteBackgroundColor;
@@ -551,7 +676,7 @@ export class Display {
return this.textColor ?? defaultColor;
}
getBackgroundColor(): {backgroundColor: string} {
getBackgroundColor(): { backgroundColor: string } {
return this.backgroundColor;
}

View File

@@ -672,8 +672,8 @@ export const DefaultTheme = {
},
introSliderHeader: {
marginTop: isIOS()
? Constants.statusBarHeight + 40
: StatusBar.currentHeight + 40,
? Constants.statusBarHeight + 40
: StatusBar.currentHeight + 40,
width: '100%',
marginBottom: 50,
},
@@ -2081,6 +2081,88 @@ export const DefaultTheme = {
},
}),
DisclosureOverlayStyles: StyleSheet.create({
overlay: {
padding: 0,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
width: '100%',
position: 'absolute',
bottom: 0,
backgroundColor: '#fff',
},
outerView: {
padding: 16,
borderBottomWidth: 1,
borderBottomColor: Colors.LightGrey,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
listView: {
marginHorizontal: 16,
marginVertical: 6,
padding: 12,
borderRadius: 10,
borderWidth: 1,
},
noteView: {
marginHorizontal: 16,
marginBottom: 30,
padding: 12,
borderRadius: 8,
backgroundColor: '#FFFBEB',
borderColor: '#FEE685',
borderWidth: 1,
flexDirection: 'row',
alignItems: 'flex-start',
},
titleText: {
fontSize: 17,
textAlign: 'left',
fontFamily: 'Inter_600SemiBold',
paddingTop: 20,
},
titleDescription: {
fontSize: 13,
textAlign: 'left',
marginTop: 4,
color: '#747474',
},
noteTitleText: {
fontSize: 14,
fontFamily: 'Inter_600SemiBold',
color: '#973C00',
marginBottom: 5,
},
noteDescriptionText:{
fontSize: 13,
color: '#973C00',
fontFamily: 'Inter_400Regular',
lineHeight: 18,
textAlign: 'left',
marginLeft: -25
}
}),
DisclosureInfo: StyleSheet.create({
view: {
marginTop: -16,
marginBottom: 16,
marginHorizontal: 10,
padding: 12,
backgroundColor: '#EFF6FF',
borderRadius: 10,
borderWidth: 1,
borderColor: '#BEDBFF',
},
text: {
fontSize: 14,
fontFamily: 'Inter_400Regular',
color: 'black',
flex: 1,
},
}),
ICON_SMALL_SIZE: 16,
ICON_MID_SIZE: 22,
ICON_LARGE_SIZE: 33,
@@ -2120,7 +2202,7 @@ export const DefaultTheme = {
}
const [top, end, bottom, start] =
typeof values === 'string' ? values.split(' ').map(Number) : values;
typeof values === 'string' ? values.split(' ').map(Number) : values;
return {
[`${type}Top`]: top,
@@ -2148,4 +2230,4 @@ function generateBoxShadowStyle() {
elevation: 4,
shadowColor: '#000',
};
}
}

View File

@@ -2082,6 +2082,87 @@ export const PurpleTheme = {
alignItems: 'center',
},
}),
DisclosureOverlayStyles: StyleSheet.create({
overlay:{
padding: 0,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
width: '100%',
position: 'absolute',
bottom: 0,
backgroundColor: '#fff',
},
outerView:{
padding: 16,
borderBottomWidth: 1,
borderBottomColor: Colors.LightGrey,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
listView:{
marginHorizontal: 16,
marginVertical: 6,
padding: 12,
borderRadius: 10,
borderWidth: 1,
},
noteView:{
marginHorizontal: 16,
marginBottom: 30,
padding: 12,
borderRadius: 8,
backgroundColor: '#FFFBEB',
borderColor: '#FEE685',
borderWidth: 1,
flexDirection: 'row',
alignItems: 'flex-start',
},
titleText: {
fontSize: 17,
textAlign: 'left',
fontFamily: 'Inter_600SemiBold',
paddingTop: 20,
},
titleDescription: {
fontSize: 13,
textAlign: 'left',
marginTop: 4,
color: '#747474',
},
noteTitleText: {
fontSize: 14,
fontFamily: 'Inter_600SemiBold',
color: '#973C00',
marginBottom: 5,
},
noteDescriptionText:{
fontSize: 13,
color: '#973C00',
fontFamily: 'Inter_400Regular',
lineHeight: 18,
textAlign: 'left',
marginLeft: -25
}
}),
DisclosureInfo: StyleSheet.create({
view:{
marginTop: -16,
marginBottom:16,
marginHorizontal: 10,
padding: 12,
backgroundColor: '#EFF6FF',
borderRadius: 10,
borderWidth: 1,
borderColor: '#BEDBFF',
},
text:{
fontSize: 14,
fontFamily: 'Inter_400Regular',
color: 'black',
flex: 1,
}
}),
ICON_SMALL_SIZE: 16,
ICON_MID_SIZE: 22,