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

enhancement: nft metadata display #5237

Merged
merged 21 commits into from Nov 21, 2022
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ae4b380
enhancement: adds stringify parameter to add whitespaces in metadata …
jeeanribeiro Nov 17, 2022
7feeaa5
enhancement: adds whitespace-pre-wrap class only for metadata in Gene…
jeeanribeiro Nov 17, 2022
d2e196d
enhancement: adds validation for nft metadata
jeeanribeiro Nov 17, 2022
b9a3d46
enhancement: partial type of activity prop
jeeanribeiro Nov 17, 2022
68e7289
feat: adds NftMetadataInformation component
jeeanribeiro Nov 17, 2022
ab810cb
feat: adds metadata tab
jeeanribeiro Nov 17, 2022
1fd1763
fix: only show metadata in transaction tab when it is not nft
jeeanribeiro Nov 17, 2022
5441dad
fix: correct use of nft metadata
jeeanribeiro Nov 18, 2022
1540336
fix: only show metadata tab if it has parsed metadata
jeeanribeiro Nov 18, 2022
cfcd881
fix: only show immutable issuer and other optional values if they are…
jeeanribeiro Nov 18, 2022
ab4abf2
fix: expression to identify IRC27 metadata
jeeanribeiro Nov 18, 2022
e4708f7
fix: display of text and styling
jeeanribeiro Nov 18, 2022
7a860b4
enhancement: nft metadata in MintNftConfirmationPopup
jeeanribeiro Nov 18, 2022
448288d
Merge branch 'stardust-develop' into enhancement/nft-metadata-display
jeeanribeiro Nov 18, 2022
561b155
fix: code formatting
jeeanribeiro Nov 18, 2022
2263a41
fix: uri validation and parsed metadata failed return
jeeanribeiro Nov 18, 2022
9c1ce04
fix: wraps all parseNftMetadata function content in try catch block
jeeanribeiro Nov 18, 2022
d0272d8
fix: removes handleError from parseNftMetadata
jeeanribeiro Nov 18, 2022
e54c2a8
fixes from code review
jeeanribeiro Nov 21, 2022
fdff63a
Merge branch 'stardust-develop' into enhancement/nft-metadata-display
jeeanribeiro Nov 21, 2022
9293edc
fix: fix component props + types
MarkNerdi996 Nov 21, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
38 changes: 26 additions & 12 deletions packages/shared/components/molecules/ActivityInformation.svelte
Expand Up @@ -5,27 +5,39 @@
AliasActivityInformation,
NftActivityInformation,
} from 'shared/components'
import { ActivityType, Activity } from '@core/wallet'
import { ActivityType, Activity, NftActivity } from '@core/wallet'
import NftMetadataInformation from './activity-info/NftMetadataInformation.svelte'
import { getNftByIdFromAllAccountNfts } from '@core/nfts'
import { selectedAccountIndex } from '@core/account'

export let activity: Activity
export let activity: Partial<Activity> = {}

enum Tab {
Transaction = 'general.transaction',
Alias = 'general.alias',
Nft = 'general.nft',
Metadata = 'general.metadata',
}

let hasMetadata = false
$: {
const storedNft = getNftByIdFromAllAccountNfts($selectedAccountIndex, (activity as NftActivity)?.nftId)
hasMetadata = !!storedNft?.parsedMetadata
}

let tabs: Tab[] = []
switch (activity.type) {
case ActivityType.Transaction:
tabs = [Tab.Transaction]
break
case ActivityType.Alias:
tabs = [Tab.Transaction, Tab.Alias]
break
case ActivityType.Nft:
tabs = [Tab.Transaction, Tab.Nft]
break
$: {
switch (activity.type) {
case ActivityType.Transaction:
tabs = [Tab.Transaction]
break
case ActivityType.Alias:
tabs = [Tab.Transaction, Tab.Alias]
break
case ActivityType.Nft:
tabs = [Tab.Transaction, Tab.Nft, ...(hasMetadata ? [Tab.Metadata] : [])]
break
}
}

let activeTab = Tab.Transaction
Expand All @@ -41,5 +53,7 @@
<AliasActivityInformation {activity} />
{:else if activeTab === Tab.Nft}
<NftActivityInformation {activity} />
{:else if activeTab === Tab.Metadata}
<NftMetadataInformation {activity} />
{/if}
</activity-details>
Expand Up @@ -7,7 +7,7 @@

import { Icon as IconEnum } from '@lib/auxiliary/icon'

export let activity: Activity
export let activity: Partial<Activity> = {}
jeeanribeiro marked this conversation as resolved.
Show resolved Hide resolved

$: isTimelocked = activity?.asyncData?.timelockDate > $time
</script>
Expand Down
Expand Up @@ -2,14 +2,14 @@
import { KeyValueBox } from 'shared/components'
import { formatDate, localize } from '@core/i18n'
import { activeProfile } from '@core/profile'
import { formatTokenAmountPrecise, BaseActivity } from '@core/wallet'
import { Activity, formatTokenAmountPrecise } from '@core/wallet'
import { BASE_TOKEN } from '@core/network'
import { getOfficialExplorerUrl } from '@core/network/utils'
import { Platform } from '@core/app'
import { truncateString } from '@core/utils'
import { setClipboard } from '@core/utils'

export let activity: BaseActivity
export let activity: Activity

const explorerUrl = getOfficialExplorerUrl($activeProfile?.networkProtocol, $activeProfile?.networkType)

Expand Down
Expand Up @@ -18,5 +18,7 @@
</script>

{#each Object.entries(detailsList) as [key, value]}
<KeyValueBox keyText={localize(`general.${key}`)} valueText={value} isCopyable />
{#if value}
<KeyValueBox keyText={localize(`general.${key}`)} valueText={value} isCopyable />
{/if}
{/each}
@@ -0,0 +1,80 @@
<script lang="typescript">
import { KeyValueBox } from 'shared/components'

import { localize } from '@core/i18n'
import { getNftByIdFromAllAccountNfts, IIrc27Metadata } from '@core/nfts'
import { Activity, NftActivity } from '@core/wallet'
import { selectedAccountIndex } from '@core/account'

export let activity: Partial<Activity> = {}
export let nftMetadata: Partial<IIrc27Metadata> = {}

type NftMetadataDetailsList = {
[key in keyof IIrc27Metadata]: {
data: unknown
isTooltipVisible?: boolean
}
}

$: storedNft = getNftByIdFromAllAccountNfts($selectedAccountIndex, (activity as NftActivity)?.nftId)
$: nftMetadataDetailsList = createNftMetadataDetailsList(
storedNft?.parsedMetadata ?? (nftMetadata as IIrc27Metadata)
)

function createNftMetadataDetailsList(
metadata: IIrc27Metadata | string
): NftMetadataDetailsList | { metadata: { data: string } } {
if (typeof metadata === 'string') {
return { metadata: { data: metadata } }
}
return createIrc27NftMetadataDetailsList(metadata)
}

function createIrc27NftMetadataDetailsList(metadata: IIrc27Metadata): NftMetadataDetailsList {
return {
...(metadata?.standard && {
standard: { data: metadata.standard, isTooltipVisible: true },
}),
...(metadata?.version && {
version: { data: metadata.version },
}),
...(metadata?.name && {
name: { data: metadata.name },
}),
...(metadata?.type && {
type: { data: metadata.type as string, isTooltipVisible: true },
}),
...(metadata?.uri && {
uri: { data: metadata.uri },
}),
...(metadata?.collectionId && {
collectionId: { data: metadata.collectionId, isTooltipVisible: true },
}),
...(metadata?.collectionName && {
collectionName: { data: metadata.collectionName },
}),
...(metadata?.royalties && {
royalties: { data: metadata.royalties, isTooltipVisible: true },
}),
...(metadata?.issuerName && {
issuerName: { data: metadata.issuerName, isTooltipVisible: true },
}),
...(metadata?.description && {
description: { data: metadata.description },
}),
...(metadata?.attributes && {
attributes: { data: metadata.attributes, isTooltipVisible: true },
}),
}
}
</script>

{#each Object.entries(nftMetadataDetailsList) as [key, value]}
<KeyValueBox
keyText={localize(`views.collectibles.metadata.${key}`)}
valueText={value.data}
tooltipText={value.isTooltipVisible ? localize(`tooltips.transactionDetails.nftMetadata.${key}`) : undefined}
classes={key === 'metadata' ? 'whitespace-pre-wrap' : ''}
isCopyable
jeeanribeiro marked this conversation as resolved.
Show resolved Hide resolved
/>
{/each}
@@ -1,3 +1,4 @@
export { default as AliasActivityInformation } from './AliasActivityInformation'
export { default as GenericActivityInformation } from './GenericActivityInformation'
export { default as NftActivityInformation } from './NftActivityInformation'
export { default as NftMetadataInformation } from './NftMetadataInformation'
@@ -1,9 +1,9 @@
<script lang="typescript">
import { onMount } from 'svelte'
import { Button, Text, FontWeight, NftActivityDetails, ActivityInformation } from 'shared/components'
import { Button, Text, FontWeight, NftActivityDetails, NftMetadataInformation } from 'shared/components'
import { localize } from '@core/i18n'
import { selectedAccount } from '@core/account'
import { ActivityDirection, mintNft, mintNftDetails } from '@core/wallet'
import { mintNft, mintNftDetails } from '@core/wallet'
import { checkActiveProfileAuth } from '@core/profile'
import { handleError } from '@core/error/handlers/handleError'
import { closePopup, openPopup } from '@auxiliary/popup'
Expand Down Expand Up @@ -70,9 +70,9 @@
<div class="space-y-2 max-h-100 scrollable-y flex-1">
<nft-details>
<NftActivityDetails />
<ActivityInformation
activity={{ metadata: JSON.stringify(actualMintDetails), direction: ActivityDirection.Outgoing }}
/>
<div class="w-full h-full space-y-2 flex flex-auto flex-col flex-shrink-0">
<NftMetadataInformation nftMetadata={actualMintDetails} />
</div>
</nft-details>
</div>
<div class="flex flex-row flex-nowrap w-full space-x-4">
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/lib/core/nfts/interfaces/nft.interface.ts
Expand Up @@ -6,6 +6,6 @@ export interface INft {
name: string
issuer: AddressTypes
metadata: string
parsedMetadata?: IIrc27Metadata
parsedMetadata?: IIrc27Metadata | string
jeeanribeiro marked this conversation as resolved.
Show resolved Hide resolved
isOwned: boolean
}
Expand Up @@ -11,7 +11,7 @@ export function buildNftFromNftOutput(nftOutput: INftOutput, outputId: string, i
const parsedMetadata = parseNftMetadata(metadata)
return {
id,
name: parsedMetadata?.name ?? DEFAULT_NFT_NAME,
name: typeof parsedMetadata === 'string' ? DEFAULT_NFT_NAME : parsedMetadata?.name ?? DEFAULT_NFT_NAME,
issuer,
isOwned,
metadata,
Expand Down
120 changes: 104 additions & 16 deletions packages/shared/lib/core/nfts/utils/parseNftMetadata.ts
@@ -1,22 +1,110 @@
import { IIrc27Metadata, MimeType } from '@core/nfts'
import { networkHrp } from '@core/network'
import { IIrc27Metadata, MimeType, SupportedMimeType } from '@core/nfts'
import { isValidUri, validateBech32Address } from '@core/utils'
import { Converter } from '@core/utils/convert'
import { TokenStandard } from '@core/wallet'
import { get } from 'svelte/store'

export function parseNftMetadata(metadata: string): IIrc27Metadata {
const parsedData = metadata ? JSON.parse(Converter.hexToUtf8(metadata)) : {}
export function parseNftMetadata(metadata: string): IIrc27Metadata | string {
try {
const convertedData = Converter.hexToUtf8(metadata)
if (!convertedData.includes(`"standard":"${TokenStandard.IRC27}"`)) {
return JSON.stringify(JSON.parse(convertedData), null, 2)
}

// TODO: Add some validation that everything is correct
const parsedMetadata: IIrc27Metadata = {
standard: parsedData.standard,
version: parsedData.version,
type: parsedData.type as MimeType,
uri: parsedData.uri,
name: parsedData.name,
collectionName: parsedData.collectionName,
royalties: parsedData.royalties,
issuerName: parsedData.issuerName,
description: parsedData.description,
attributes: parsedData.attributes,
const parsedData = metadata ? JSON.parse(Converter.hexToUtf8(metadata)) : {}
validate(parsedData)
const parsedMetadata: IIrc27Metadata = {
standard: parsedData.standard,
version: parsedData.version,
type: parsedData.type as MimeType,
uri: parsedData.uri,
name: parsedData.name,
collectionName: parsedData.collectionName,
royalties: parsedData.royalties,
issuerName: parsedData.issuerName,
description: parsedData.description,
attributes: parsedData.attributes,
}
return parsedMetadata
} catch (error) {
return undefined
}
}

function validate(data: IIrc27Metadata): void {
if (!data.standard || data.standard !== TokenStandard.IRC27) {
throw 'Invalid standard, must be "IRC27"'
}

if (!Object.keys(SupportedMimeType).includes(data.type)) {
throw 'Invalid MimeType, check if the file type is supported'
}

if (data.name.length === 0) {
throw 'Empty name, it is a required field'
}

return parsedMetadata
if (data.uri.length === 0) {
throw 'Empty URI'
} else if (!isValidUri(data.uri)) {
throw 'Invalid URI'
}

if (data.royalties) {
validateRoyalties(data)
}

if (data.attributes) {
validateAttributes(data)
}
}

function validateRoyalties(data: IIrc27Metadata): void {
jeeanribeiro marked this conversation as resolved.
Show resolved Hide resolved
const isKeysValid = Object.keys(data.royalties).every((key) => !validateBech32Address(get(networkHrp), key))
if (!isKeysValid) {
throw `Invalid royalty address, must be a valid ${get(networkHrp)} address where royalties will be sent to.`
}

const isValuesValid = Object.values(data.royalties).every((value) => value >= 0 && value <= 1)
if (!isValuesValid) {
throw 'Invalid royalty value, it must be a numeric decimal representative of the percentage required ie. 0.05'
}

const isSumValid = Object.values(data.royalties).reduce((acc, val) => acc + val, 0) <= 1
if (!isSumValid) {
throw 'Invalid royalty value, the sum of all royalties must be less than or equal to 1'
}
}

function validateAttributes(data: IIrc27Metadata): void {
jeeanribeiro marked this conversation as resolved.
Show resolved Hide resolved
if (!Array.isArray(data.attributes)) {
throw 'Attributes must be an array'
}
const isArrayOfObjects = data.attributes.every(
(attribute) => typeof attribute === 'object' && !Array.isArray(attribute) && attribute !== null
)
if (!isArrayOfObjects) {
throw 'Attributes must be an array of objects'
}
const isKeysValid = data.attributes.every(
(attribute) =>
Object.keys(attribute).every((key) => key === 'trait_type' || key === 'value') &&
Object.keys(attribute).filter((key) => key === 'trait_type').length === 1 &&
Object.keys(attribute).filter((key) => key === 'value').length === 1
)
if (!isKeysValid) {
throw 'Invalid key, attributes must have the keys "trait_type" and "value"'
}
const isValuesValid = data.attributes.every(
(attribute) =>
(typeof attribute.trait_type === 'string' &&
attribute.trait_type.length > 0 &&
typeof attribute.value === 'string' &&
attribute.value.length > 0) ||
typeof attribute.value === 'number'
)
if (!isValuesValid) {
throw 'Invalid value, "trait_type" must be a non empty string and "value" must be a non empty string or a number'
}
}
3 changes: 2 additions & 1 deletion packages/shared/locales/en.json
Expand Up @@ -572,7 +572,8 @@
"royalties": "Royalties",
"issuerName": "Issuer Name",
"description": "Description",
"attributes": "Attributes"
"attributes": "Attributes",
"metadata": "Metadata"
}
}
},
Expand Down