Skip to content

Commit

Permalink
Merge pull request #345 from Concordium/support-localization
Browse files Browse the repository at this point in the history
Add initial support for localization
  • Loading branch information
orhoj authored Aug 23, 2023
2 parents 1cc7398 + b1d5593 commit d98f2b8
Show file tree
Hide file tree
Showing 7 changed files with 149 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
createPublicKeyIdentifier,
fetchCredentialMetadata,
fetchCredentialSchema,
fetchLocalization,
getCredentialRegistryContractAddress,
} from '@shared/utils/verifiable-credential-helpers';
import { APIVerifiableCredential } from '@concordium/browser-wallet-api-helpers';
Expand All @@ -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<boolean>(false);
const [web3IdCredentials, setWeb3IdCredentials] = useAtom(sessionTemporaryVerifiableCredentialsAtom);
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -156,6 +179,7 @@ export default function AddWeb3IdCredential({ onAllow, onReject }: Props) {
schema={schema}
credentialStatus={VerifiableCredentialStatus.NotActivated}
metadata={metadata}
localization={localization}
/>
</>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
): (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],
};
Expand Down Expand Up @@ -116,6 +131,7 @@ interface CardProps extends ClassName {
credentialStatus: VerifiableCredentialStatus;
metadata: VerifiableCredentialMetadata;
onClick?: () => void;
localization?: Record<string, string>;
}

export function VerifiableCredentialCard({
Expand All @@ -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 (
<ClickableVerifiableCredential className={className} onClick={onClick} metadata={metadata}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ interface CredentialDetailsProps extends ClassName {
status: VerifiableCredentialStatus;
metadata: VerifiableCredentialMetadata;
schema: VerifiableCredentialSchema;
localization?: Record<string, string>;
backButtonOnClick: () => void;
}

Expand All @@ -143,6 +144,7 @@ export default function VerifiableCredentialDetails({
status,
metadata,
schema,
localization,
backButtonOnClick,
className,
}: CredentialDetailsProps) {
Expand Down Expand Up @@ -258,6 +260,7 @@ export default function VerifiableCredentialDetails({
schema={schema}
credentialStatus={status}
metadata={metadata}
localization={localization}
/>
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
CredentialQueryResponse,
IssuerMetadata,
VerifiableCredentialMetadata,
fetchLocalization,
fetchIssuerMetadata,
getCredentialHolderId,
getCredentialRegistryContractAddress,
Expand All @@ -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.
Expand All @@ -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<VerifiableCredentialSchema>();
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;
}
Expand Down Expand Up @@ -111,6 +113,62 @@ export function useCredentialMetadata(credential?: VerifiableCredential) {
return metadata;
}

interface SuccessfulLocalizationResult {
loading: false;
result: Record<string, string>;
}

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<LocalizationResult>({ 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -77,15 +78,17 @@ export function VerifiableCredentialCardWithStatusFromChain({
onClick?: (
status: VerifiableCredentialStatus,
schema: VerifiableCredentialSchema,
metadata: VerifiableCredentialMetadata
metadata: VerifiableCredentialMetadata,
localization?: Record<string, string>
) => 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;
}

Expand All @@ -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}
/>
);
}
Expand All @@ -118,6 +122,7 @@ export default function VerifiableCredentialList() {
status: VerifiableCredentialStatus;
schema: VerifiableCredentialSchema;
metadata: VerifiableCredentialMetadata;
localization?: Record<string, string>;
}>();
const [schemas, setSchemas] = useAtom(storedVerifiableCredentialSchemasAtom);
const [storedMetadata, setStoredMetadata] = useAtom(storedVerifiableCredentialMetadataAtom);
Expand Down Expand Up @@ -170,6 +175,7 @@ export default function VerifiableCredentialList() {
schema={selected.schema}
status={selected.status}
metadata={selected.metadata}
localization={selected.localization}
backButtonOnClick={() => setSelected(undefined)}
/>
);
Expand All @@ -187,8 +193,9 @@ export default function VerifiableCredentialList() {
onClick={(
status: VerifiableCredentialStatus,
schema: VerifiableCredentialSchema,
metadata: VerifiableCredentialMetadata
) => setSelected({ credential, status, schema, metadata })}
metadata: VerifiableCredentialMetadata,
localization?: Record<string, string>
) => setSelected({ credential, status, schema, metadata, localization })}
/>
);
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
}

Expand All @@ -202,6 +203,7 @@ export default function DisplayWeb3Statement({
schema={schema}
credentialStatus={VerifiableCredentialStatus.Active}
metadata={metadata}
localization={localization.result}
/>

<CredentialSelector
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,19 @@ const verifiableCredentialMetadataSchema = {
},
};

const localizationRecordSchema = {
$ref: '#/definitions/Localization',
$schema: 'http://json-schema.org/draft-07/schema#',
definitions: {
Localization: {
additionalProperties: {
type: 'string',
},
type: 'object',
},
},
};

const issuerMetadataSchema = {
$ref: '#/definitions/IssuerMetadata',
$schema: 'http://json-schema.org/draft-07/schema#',
Expand Down Expand Up @@ -650,6 +663,7 @@ async function fetchDataFromUrl<T>(
jsonSchema:
| typeof verifiableCredentialMetadataSchema
| typeof verifiableCredentialSchemaSchema
| typeof localizationRecordSchema
| typeof issuerMetadataSchema
): Promise<T> {
const response = await fetch(url, {
Expand Down Expand Up @@ -707,6 +721,13 @@ export async function fetchCredentialMetadata(
return fetchDataFromUrl(metadata, abortController, verifiableCredentialMetadataSchema);
}

export async function fetchLocalization(
url: MetadataUrl,
abortController: AbortController
): Promise<Record<string, string>> {
return fetchDataFromUrl(url, abortController, localizationRecordSchema);
}

export interface IssuerMetadata {
name?: string;
icon?: MetadataUrl;
Expand Down

0 comments on commit d98f2b8

Please sign in to comment.