mirror of
https://github.com/mosip/inji-wallet.git
synced 2026-01-09 05:27:57 -05:00
[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:
186
components/VC/Views/ShareableInfoModal.tsx
Normal file
186
components/VC/Views/ShareableInfoModal.tsx
Normal 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>
|
||||
|
||||
);
|
||||
};
|
||||
@@ -94,7 +94,6 @@ export const VCCardView: React.FC<VCItemProps> = ({
|
||||
if (!isVCLoaded(controller.credential) || !wellknown || !vc) {
|
||||
return <VCCardSkeleton />;
|
||||
}
|
||||
|
||||
const CardViewContent = () => (
|
||||
<VCCardViewContent
|
||||
vcMetadata={vcMetadata}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user