Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add initial support for localization #345

Merged
merged 4 commits into from
Aug 23, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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<boolean>(false);
const [web3IdCredentials, setWeb3IdCredentials] = useAtom(sessionTemporaryVerifiableCredentialsAtom);
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -154,6 +177,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,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<string, string>
): (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;
orhoj marked this conversation as resolved.
Show resolved Hide resolved

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 +132,7 @@ interface CardProps extends ClassName {
credentialStatus: VerifiableCredentialStatus;
metadata: VerifiableCredentialMetadata;
onClick?: () => void;
localization?: Record<string, string>;
}

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

Expand All @@ -90,6 +91,7 @@ export default function VerifiableCredentialDetails({
status,
metadata,
schema,
localization,
backButtonOnClick,
className,
}: CredentialDetailsProps) {
Expand Down Expand Up @@ -204,6 +206,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 @@ -3,6 +3,7 @@ import { VerifiableCredential, VerifiableCredentialStatus, VerifiableCredentialS
import {
CredentialQueryResponse,
VerifiableCredentialMetadata,
fetchLocalization,
getCredentialHolderId,
getCredentialRegistryContractAddress,
getVerifiableCredentialEntry,
Expand All @@ -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.
Expand All @@ -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<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 @@ -108,6 +110,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 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
getChangesToCredentialSchemas,
} from '@shared/utils/verifiable-credential-helpers';
import {
useCredentialLocalization,
useCredentialMetadata,
useCredentialSchema,
useCredentialStatus,
Expand Down Expand Up @@ -56,15 +57,17 @@ 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 @@ -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}
/>
);
}
Expand All @@ -97,6 +101,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 @@ -130,6 +135,7 @@ export default function VerifiableCredentialList() {
schema={selected.schema}
status={selected.status}
metadata={selected.metadata}
localization={selected.localization}
backButtonOnClick={() => setSelected(undefined)}
/>
);
Expand All @@ -148,8 +154,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,18 @@ const verifiableCredentialMetadataSchema = {
},
};

const localizationRecordSchema = {
$ref: '#/definitions/Localization',
$schema: 'http://json-schema.org/draft-07/schema#',
definitions: {
Localization: {
additionalProperties: {
type: 'string',
},
type: 'object',
},
},
};
export interface VerifiableCredentialMetadata {
title: string;
logo: MetadataUrl;
Expand Down Expand Up @@ -606,7 +618,10 @@ export async function getVerifiableCredentialEntry(
async function fetchDataFromUrl<T>(
{ url, hash }: MetadataUrl,
abortController: AbortController,
jsonSchema: typeof verifiableCredentialMetadataSchema | typeof verifiableCredentialSchemaSchema
jsonSchema:
| typeof verifiableCredentialMetadataSchema
| typeof verifiableCredentialSchemaSchema
| typeof localizationRecordSchema
): Promise<T> {
const response = await fetch(url, {
headers: new Headers({ 'Access-Control-Allow-Origin': '*' }),
Expand Down Expand Up @@ -663,6 +678,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);
}

/**
* 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
Expand Down
Loading