diff --git a/packages/browser-wallet/src/popup/pages/AddWeb3IdCredential/AddWeb3IdCredential.tsx b/packages/browser-wallet/src/popup/pages/AddWeb3IdCredential/AddWeb3IdCredential.tsx index b1e91024..5b382f9e 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'; @@ -46,6 +47,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); @@ -98,6 +100,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'); @@ -156,6 +179,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 30765062..ddc59202 100644 --- a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialCard.tsx +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialCard.tsx @@ -72,22 +72,37 @@ 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]) => { const attributeSchema = schema.properties.credentialSubject.properties.attributes.properties[value[0]]; if (!attributeSchema) { throw new Error(`Missing attribute schema for key: ${value[0]}`); } + let { title } = attributeSchema; + + 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 +131,7 @@ interface CardProps extends ClassName { credentialStatus: VerifiableCredentialStatus; metadata: VerifiableCredentialMetadata; onClick?: () => void; + localization?: Record; } export function VerifiableCredentialCard({ @@ -125,8 +141,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 f83785fe..7c74de00 100644 --- a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialDetails.tsx +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialDetails.tsx @@ -135,6 +135,7 @@ interface CredentialDetailsProps extends ClassName { status: VerifiableCredentialStatus; metadata: VerifiableCredentialMetadata; schema: VerifiableCredentialSchema; + localization?: Record; backButtonOnClick: () => void; } @@ -143,6 +144,7 @@ export default function VerifiableCredentialDetails({ status, metadata, schema, + localization, backButtonOnClick, className, }: CredentialDetailsProps) { @@ -258,6 +260,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 107b7def..d1822777 100644 --- a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialHooks.tsx +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialHooks.tsx @@ -4,6 +4,7 @@ import { CredentialQueryResponse, IssuerMetadata, VerifiableCredentialMetadata, + fetchLocalization, fetchIssuerMetadata, getCredentialHolderId, getCredentialRegistryContractAddress, @@ -19,6 +20,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. @@ -45,19 +47,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; } @@ -111,6 +113,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), JSON.stringify(schema), i18n.language]); + + return localization; +} + /** * Retrieves the issuer metadata JSON file. This is done by getting the credential * registry metadata from the credential registry contract, and then fetching the diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialList.tsx b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialList.tsx index f00c5058..21542d2e 100644 --- a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialList.tsx +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialList.tsx @@ -16,6 +16,7 @@ import { import { popupMessageHandler } from '@popup/shared/message-handler'; import { InternalMessageType } from '@concordium/browser-wallet-message-hub'; import { + useCredentialLocalization, useCredentialMetadata, useCredentialSchema, useCredentialStatus, @@ -77,15 +78,17 @@ export 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; } @@ -96,11 +99,12 @@ export function VerifiableCredentialCardWithStatusFromChain({ className={className} onClick={() => { if (onClick) { - onClick(status, schema, metadata); + onClick(status, schema, metadata, localization.result); } }} credentialStatus={status} metadata={metadata} + localization={localization.result} /> ); } @@ -118,6 +122,7 @@ export default function VerifiableCredentialList() { status: VerifiableCredentialStatus; schema: VerifiableCredentialSchema; metadata: VerifiableCredentialMetadata; + localization?: Record; }>(); const [schemas, setSchemas] = useAtom(storedVerifiableCredentialSchemasAtom); const [storedMetadata, setStoredMetadata] = useAtom(storedVerifiableCredentialMetadataAtom); @@ -170,6 +175,7 @@ export default function VerifiableCredentialList() { schema={selected.schema} status={selected.status} metadata={selected.metadata} + localization={selected.localization} backButtonOnClick={() => setSelected(undefined)} /> ); @@ -187,8 +193,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 b8a4849c..0d432539 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} /> ( jsonSchema: | typeof verifiableCredentialMetadataSchema | typeof verifiableCredentialSchemaSchema + | typeof localizationRecordSchema | typeof issuerMetadataSchema ): Promise { const response = await fetch(url, { @@ -707,6 +721,13 @@ export async function fetchCredentialMetadata( return fetchDataFromUrl(metadata, abortController, verifiableCredentialMetadataSchema); } +export async function fetchLocalization( + url: MetadataUrl, + abortController: AbortController +): Promise> { + return fetchDataFromUrl(url, abortController, localizationRecordSchema); +} + export interface IssuerMetadata { name?: string; icon?: MetadataUrl;