From 9ff0f5a68801ea583c031ec06107a289480989c1 Mon Sep 17 00:00:00 2001 From: orhoj Date: Fri, 18 Aug 2023 15:54:06 +0200 Subject: [PATCH 1/3] Add initial support for localization --- .../AddWeb3IdCredential.tsx | 24 +++++++ .../VerifiableCredentialCard.tsx | 30 +++++++-- .../VerifiableCredentialDetails.tsx | 3 + .../VerifiableCredentialHooks.tsx | 64 ++++++++++++++++++- .../VerifiableCredentialList.tsx | 17 +++-- .../VerifiableCredentialStatement.tsx | 6 +- .../utils/verifiable-credential-helpers.ts | 24 ++++++- 7 files changed, 152 insertions(+), 16 deletions(-) diff --git a/packages/browser-wallet/src/popup/pages/AddWeb3IdCredential/AddWeb3IdCredential.tsx b/packages/browser-wallet/src/popup/pages/AddWeb3IdCredential/AddWeb3IdCredential.tsx index b8ea98d76..72d68dcae 100644 --- a/packages/browser-wallet/src/popup/pages/AddWeb3IdCredential/AddWeb3IdCredential.tsx +++ b/packages/browser-wallet/src/popup/pages/AddWeb3IdCredential/AddWeb3IdCredential.tsx @@ -20,6 +20,7 @@ import { createPublicKeyIdentifier, fetchCredentialMetadata, fetchCredentialSchema, + fetchLocalization, getCredentialRegistryContractAddress, } from '@shared/utils/verifiable-credential-helpers'; import { APIVerifiableCredential } from '@concordium/browser-wallet-api-helpers'; @@ -45,6 +46,7 @@ interface Location { export default function AddWeb3IdCredential({ onAllow, onReject }: Props) { const { state } = useLocation() as Location; const { t } = useTranslation('addWeb3IdCredential'); + const { i18n } = useTranslation(); const { onClose, withClose } = useContext(fullscreenPromptContext); const [acceptButtonDisabled, setAcceptButtonDisabled] = useState(false); const [web3IdCredentials, setWeb3IdCredentials] = useAtom(sessionTemporaryVerifiableCredentialsAtom); @@ -96,6 +98,27 @@ export default function AddWeb3IdCredential({ onAllow, onReject }: Props) { ); useEffect(() => () => controller.abort(), []); + const localization = useAsyncMemo( + async () => { + if (metadata === undefined) { + return undefined; + } + + if (metadata.localization === undefined) { + return undefined; + } + + const currentLanguageLocalization = metadata.localization[i18n.language]; + if (currentLanguageLocalization === undefined) { + return undefined; + } + + return fetchLocalization(currentLanguageLocalization, controller); + }, + () => setError('Failed to get localization'), + [metadata, i18n] + ); + async function addCredential(credentialSchema: VerifiableCredentialSchema) { if (!wallet) { throw new Error('Wallet is unexpectedly missing'); @@ -154,6 +177,7 @@ export default function AddWeb3IdCredential({ onAllow, onReject }: Props) { schema={schema} credentialStatus={VerifiableCredentialStatus.NotActivated} metadata={metadata} + localization={localization} /> )} diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialCard.tsx b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialCard.tsx index 30765062a..d740ce44d 100644 --- a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialCard.tsx +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialCard.tsx @@ -72,22 +72,38 @@ function ClickableVerifiableCredential({ children, onClick, metadata, className } /** - * Apply the schema to an attribute, adding the title from the schema, which + * Apply the schema and localization to an attribute, adding the title from the schema or localization, which * should be displayed to the user. * @param schema the schema to apply + * @param localization the localization to apply * @returns the attribute together with its title. * @throws if there is a mismatch in fields between the credential and the schema, i.e. the schema is invalid. */ -function applySchema( - schema: VerifiableCredentialSchema +function applySchemaAndLocalization( + schema: VerifiableCredentialSchema, + localization?: Record ): (value: [string, string | bigint]) => { title: string; key: string; value: string | bigint } { return (value: [string, string | bigint]) => { + let title; const attributeSchema = schema.properties.credentialSubject.properties.attributes.properties[value[0]]; if (!attributeSchema) { throw new Error(`Missing attribute schema for key: ${value[0]}`); } + title = attributeSchema.title; + + if (localization) { + const localizedTitle = localization[value[0]]; + if (localizedTitle !== undefined) { + title = localizedTitle; + } else { + // TODO Throw an error if we are missing a localization attribute key when we have added + // validation at the time of retrieving localization data. + // throw new Error(`Missing localization for key: ${value[0]}`); + } + } + return { - title: attributeSchema.title, + title, key: value[0], value: value[1], }; @@ -116,6 +132,7 @@ interface CardProps extends ClassName { credentialStatus: VerifiableCredentialStatus; metadata: VerifiableCredentialMetadata; onClick?: () => void; + localization?: Record; } export function VerifiableCredentialCard({ @@ -125,8 +142,11 @@ export function VerifiableCredentialCard({ credentialStatus, metadata, className, + localization, }: CardProps) { - const attributes = Object.entries(credentialSubject.attributes).map(applySchema(schema)); + const attributes = Object.entries(credentialSubject.attributes).map( + applySchemaAndLocalization(schema, localization) + ); return ( diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialDetails.tsx b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialDetails.tsx index 45905a334..c5c91de8b 100644 --- a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialDetails.tsx +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialDetails.tsx @@ -82,6 +82,7 @@ interface CredentialDetailsProps extends ClassName { status: VerifiableCredentialStatus; metadata: VerifiableCredentialMetadata; schema: VerifiableCredentialSchema; + localization?: Record; backButtonOnClick: () => void; } @@ -90,6 +91,7 @@ export default function VerifiableCredentialDetails({ status, metadata, schema, + localization, backButtonOnClick, className, }: CredentialDetailsProps) { @@ -204,6 +206,7 @@ export default function VerifiableCredentialDetails({ schema={schema} credentialStatus={status} metadata={metadata} + localization={localization} /> )} diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialHooks.tsx b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialHooks.tsx index 016263336..6d9863989 100644 --- a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialHooks.tsx +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialHooks.tsx @@ -3,6 +3,7 @@ import { VerifiableCredential, VerifiableCredentialStatus, VerifiableCredentialS import { CredentialQueryResponse, VerifiableCredentialMetadata, + fetchLocalization, getCredentialHolderId, getCredentialRegistryContractAddress, getVerifiableCredentialEntry, @@ -16,6 +17,7 @@ import { } from '@popup/store/verifiable-credential'; import { AsyncWrapper } from '@popup/store/utils'; import { ConcordiumGRPCClient } from '@concordium/web-sdk'; +import { useTranslation } from 'react-i18next'; /** * Retrieve the on-chain credential status for a verifiable credential in a CIS-4 credential registry contract. @@ -42,19 +44,19 @@ export function useCredentialStatus(credential: VerifiableCredential) { * @throws if no schema is found in storage for the provided credential * @returns the credential's schema used for rendering the credential */ -export function useCredentialSchema(credential: VerifiableCredential) { +export function useCredentialSchema(credential?: VerifiableCredential) { const [schema, setSchema] = useState(); const schemas = useAtomValue(storedVerifiableCredentialSchemasAtom); useEffect(() => { - if (!schemas.loading) { + if (!schemas.loading && credential) { const schemaValue = schemas.value[credential.credentialSchema.id]; if (!schemaValue) { throw new Error(`Attempted to find schema for credentialId: ${credential.id} but none was found!`); } setSchema(schemaValue); } - }, [schemas.loading]); + }, [credential?.id, schemas.loading]); return schema; } @@ -108,6 +110,62 @@ export function useCredentialMetadata(credential?: VerifiableCredential) { return metadata; } +interface SuccessfulLocalizationResult { + loading: false; + result: Record; +} + +interface FailedLocalizationResult { + loading: false; + result?: never; +} + +interface LoadingLocalizationResult { + loading: true; +} + +type LocalizationResult = SuccessfulLocalizationResult | FailedLocalizationResult | LoadingLocalizationResult; + +export function useCredentialLocalization(credential?: VerifiableCredential): LocalizationResult { + const [localization, setLocalization] = useState({ loading: true }); + const { i18n } = useTranslation(); + const metadata = useCredentialMetadata(credential); + const schema = useCredentialSchema(credential); + + useEffect(() => { + if (metadata === undefined || schema === undefined) { + return () => {}; + } + + // No localization is available for the provided metadata. + if (metadata.localization === undefined) { + setLocalization({ loading: false }); + return () => {}; + } + + const currentLanguageLocalization = metadata.localization[i18n.language]; + // No localization is available for the selected language. + if (currentLanguageLocalization === undefined) { + setLocalization({ loading: false }); + return () => {}; + } + + const abortController = new AbortController(); + fetchLocalization(currentLanguageLocalization, abortController) + .then((res) => { + // TODO Validate that localization is present for all keys. + setLocalization({ loading: false, result: res }); + }) + .catch(() => setLocalization({ loading: false })); + + return () => { + abortController.abort(); + }; + }, [JSON.stringify(metadata), i18n.language]); + + return localization; +} + /** * Retrieves data and uses the provided data setter to update chrome.storage with the changes found. * The dataFetcher is responsible for delivering the exact updated picture that should be set. diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialList.tsx b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialList.tsx index d2e0e9bcd..b1590e593 100644 --- a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialList.tsx +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialList.tsx @@ -14,6 +14,7 @@ import { getChangesToCredentialSchemas, } from '@shared/utils/verifiable-credential-helpers'; import { + useCredentialLocalization, useCredentialMetadata, useCredentialSchema, useCredentialStatus, @@ -56,15 +57,17 @@ function VerifiableCredentialCardWithStatusFromChain({ onClick?: ( status: VerifiableCredentialStatus, schema: VerifiableCredentialSchema, - metadata: VerifiableCredentialMetadata + metadata: VerifiableCredentialMetadata, + localization?: Record ) => void; }) { const status = useCredentialStatus(credential); const schema = useCredentialSchema(credential); const metadata = useCredentialMetadata(credential); + const localization = useCredentialLocalization(credential); // Render nothing until all the required data is available. - if (!schema || !metadata || status === undefined) { + if (!schema || !metadata || localization.loading || status === undefined) { return null; } @@ -75,11 +78,12 @@ function VerifiableCredentialCardWithStatusFromChain({ className={className} onClick={() => { if (onClick) { - onClick(status, schema, metadata); + onClick(status, schema, metadata, localization.result); } }} credentialStatus={status} metadata={metadata} + localization={localization.result} /> ); } @@ -97,6 +101,7 @@ export default function VerifiableCredentialList() { status: VerifiableCredentialStatus; schema: VerifiableCredentialSchema; metadata: VerifiableCredentialMetadata; + localization?: Record; }>(); const [schemas, setSchemas] = useAtom(storedVerifiableCredentialSchemasAtom); const [storedMetadata, setStoredMetadata] = useAtom(storedVerifiableCredentialMetadataAtom); @@ -130,6 +135,7 @@ export default function VerifiableCredentialList() { schema={selected.schema} status={selected.status} metadata={selected.metadata} + localization={selected.localization} backButtonOnClick={() => setSelected(undefined)} /> ); @@ -148,8 +154,9 @@ export default function VerifiableCredentialList() { onClick={( status: VerifiableCredentialStatus, schema: VerifiableCredentialSchema, - metadata: VerifiableCredentialMetadata - ) => setSelected({ credential, status, schema, metadata })} + metadata: VerifiableCredentialMetadata, + localization?: Record + ) => setSelected({ credential, status, schema, metadata, localization })} /> ); })} diff --git a/packages/browser-wallet/src/popup/pages/Web3ProofRequest/VerifiableCredentialStatement.tsx b/packages/browser-wallet/src/popup/pages/Web3ProofRequest/VerifiableCredentialStatement.tsx index f2d7c6c2c..41a44adf9 100644 --- a/packages/browser-wallet/src/popup/pages/Web3ProofRequest/VerifiableCredentialStatement.tsx +++ b/packages/browser-wallet/src/popup/pages/Web3ProofRequest/VerifiableCredentialStatement.tsx @@ -18,7 +18,7 @@ import { useTranslation } from 'react-i18next'; import { ClassName } from 'wallet-common-helpers'; import { DisplayStatementView, StatementLine } from '../IdProofRequest/DisplayStatement/DisplayStatement'; import { VerifiableCredentialCard } from '../VerifiableCredential/VerifiableCredentialCard'; -import { useCredentialMetadata } from '../VerifiableCredential/VerifiableCredentialHooks'; +import { useCredentialLocalization, useCredentialMetadata } from '../VerifiableCredential/VerifiableCredentialHooks'; import CredentialSelector from './CredentialSelector'; import { createWeb3IdDIDFromCredential, DisplayCredentialStatementProps, SecretStatementV2 } from './utils'; @@ -190,8 +190,9 @@ export default function DisplayWeb3Statement({ }, [chosenCredential?.id, verifiableCredentialSchemas.loading]); const metadata = useCredentialMetadata(chosenCredential); + const localization = useCredentialLocalization(chosenCredential); - if (!chosenCredential || !schema || !metadata) { + if (!chosenCredential || !schema || !metadata || localization.loading) { return null; } @@ -202,6 +203,7 @@ export default function DisplayWeb3Statement({ schema={schema} credentialStatus={VerifiableCredentialStatus.Active} metadata={metadata} + localization={localization.result} /> ( { url, hash }: MetadataUrl, abortController: AbortController, - jsonSchema: typeof verifiableCredentialMetadataSchema | typeof verifiableCredentialSchemaSchema + jsonSchema: + | typeof verifiableCredentialMetadataSchema + | typeof verifiableCredentialSchemaSchema + | typeof localizationRecordSchema ): Promise { const response = await fetch(url, { headers: new Headers({ 'Access-Control-Allow-Origin': '*' }), @@ -663,6 +678,13 @@ export async function fetchCredentialMetadata( return fetchDataFromUrl(metadata, abortController, verifiableCredentialMetadataSchema); } +export async function fetchLocalization( + url: MetadataUrl, + abortController: AbortController +): Promise> { + return fetchDataFromUrl(url, abortController, localizationRecordSchema); +} + /** * Retrieves credential schemas for each of the provided credentials. The method ensures * that duplicate schemas are not fetched multiple times, by only fetching once per From fd45ea713dd59a0ac5611d2493c4b78ef8d136fe Mon Sep 17 00:00:00 2001 From: orhoj Date: Fri, 18 Aug 2023 15:57:05 +0200 Subject: [PATCH 2/3] Add missing item to dependency array --- .../pages/VerifiableCredential/VerifiableCredentialHooks.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialHooks.tsx b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialHooks.tsx index 6d9863989..6f22bdbe0 100644 --- a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialHooks.tsx +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialHooks.tsx @@ -161,7 +161,7 @@ export function useCredentialLocalization(credential?: VerifiableCredential): Lo return () => { abortController.abort(); }; - }, [JSON.stringify(metadata), i18n.language]); + }, [JSON.stringify(metadata), JSON.stringify(schema), i18n.language]); return localization; } From 8a9e932cdcc1bff7961186e7bc6947a9174b8c09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20=C3=98rh=C3=B8j?= Date: Wed, 23 Aug 2023 10:04:19 +0200 Subject: [PATCH 3/3] Refactor setting variable --- .../pages/VerifiableCredential/VerifiableCredentialCard.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialCard.tsx b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialCard.tsx index d740ce44d..ddc592026 100644 --- a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialCard.tsx +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialCard.tsx @@ -84,12 +84,11 @@ function applySchemaAndLocalization( localization?: Record ): (value: [string, string | bigint]) => { title: string; key: string; value: string | bigint } { return (value: [string, string | bigint]) => { - let title; const attributeSchema = schema.properties.credentialSubject.properties.attributes.properties[value[0]]; if (!attributeSchema) { throw new Error(`Missing attribute schema for key: ${value[0]}`); } - title = attributeSchema.title; + let { title } = attributeSchema; if (localization) { const localizedTitle = localization[value[0]];