diff --git a/packages/apps-routing/src/index.ts b/packages/apps-routing/src/index.ts index 9663bec70571..9740a4541f95 100644 --- a/packages/apps-routing/src/index.ts +++ b/packages/apps-routing/src/index.ts @@ -19,6 +19,7 @@ import files from './files'; import gilt from './gilt'; import js from './js'; import membership from './membership'; +import nfts from './nfts'; import parachains from './parachains'; import poll from './poll'; import rpc from './rpc'; @@ -52,6 +53,7 @@ export default function create (t: TFunction): Routes { parachains(t), gilt(t), assets(t), + nfts(t), society(t), calendar(t), contracts(t), diff --git a/packages/apps-routing/src/nfts.ts b/packages/apps-routing/src/nfts.ts new file mode 100644 index 000000000000..689f46cabd86 --- /dev/null +++ b/packages/apps-routing/src/nfts.ts @@ -0,0 +1,22 @@ +// Copyright 2017-2022 @polkadot/apps-routing authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { TFunction } from 'i18next'; +import type { Route } from './types'; + +import Component from '@polkadot/app-nfts'; + +export default function create (t: TFunction): Route { + return { + Component, + display: { + needsApi: [ + 'tx.uniques.create' + ] + }, + group: 'network', + icon: 'shopping-cart', + name: 'nfts', + text: t('nav.nfts', 'NFTs', { ns: 'apps-routing' }) + }; +} diff --git a/packages/apps/public/locales/en/apps-routing.json b/packages/apps/public/locales/en/apps-routing.json index 165a57225443..8a81f3d6e197 100644 --- a/packages/apps/public/locales/en/apps-routing.json +++ b/packages/apps/public/locales/en/apps-routing.json @@ -14,6 +14,7 @@ "nav.gilt": "Gilt", "nav.js": "JavaScript", "nav.membership": "Membership", + "nav.nfts": "NFTs", "nav.parachains": "Parachains", "nav.poll": "Token poll", "nav.rpc": "RPC calls", @@ -27,4 +28,4 @@ "nav.teleport": "Teleport", "nav.transfer": "Transfer", "nav.treasury": "Treasury" -} \ No newline at end of file +} diff --git a/packages/page-nfts/.skip-build b/packages/page-nfts/.skip-build new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/page-nfts/.skip-npm b/packages/page-nfts/.skip-npm new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/page-nfts/LICENSE b/packages/page-nfts/LICENSE new file mode 100644 index 000000000000..0d381b2e97dc --- /dev/null +++ b/packages/page-nfts/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/packages/page-nfts/README.md b/packages/page-nfts/README.md new file mode 100644 index 000000000000..49fee6ede760 --- /dev/null +++ b/packages/page-nfts/README.md @@ -0,0 +1 @@ +# @polkadot/app-nfts diff --git a/packages/page-nfts/package.json b/packages/page-nfts/package.json new file mode 100644 index 000000000000..15f66ee45d77 --- /dev/null +++ b/packages/page-nfts/package.json @@ -0,0 +1,19 @@ +{ + "bugs": "https://github.com/polkadot-js/apps/issues", + "homepage": "https://github.com/polkadot-js/apps/tree/master/packages/page-nfts#readme", + "license": "Apache-2.0", + "name": "@polkadot/app-nfts", + "private": true, + "repository": { + "directory": "packages/page-nfts", + "type": "git", + "url": "https://github.com/polkadot-js/apps.git" + }, + "sideEffects": false, + "type": "module", + "version": "0.109.2-3", + "dependencies": { + "@babel/runtime": "^7.17.2", + "@polkadot/react-components": "^0.109.2-3" + } +} diff --git a/packages/page-nfts/src/AccountItems/Item.tsx b/packages/page-nfts/src/AccountItems/Item.tsx new file mode 100644 index 000000000000..2b8484206beb --- /dev/null +++ b/packages/page-nfts/src/AccountItems/Item.tsx @@ -0,0 +1,42 @@ +// Copyright 2017-2022 @polkadot/app-nfts authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ItemInfo } from './types'; + +import React from 'react'; + +import { AddressSmall, IconLink } from '@polkadot/react-components'; +import { formatNumber } from '@polkadot/util'; + +interface Props { + className?: string; + collectionName: string; + value: ItemInfo; +} + +function Item ({ className, collectionName, value: { account, id, ipfsData } }: Props): React.ReactElement { + const name = ipfsData?.name || collectionName; + const imageLink = ipfsData?.image ? `https://ipfs.io/ipfs/${ipfsData.image}` : ''; + + return ( + +

{formatNumber(id)}

+ + { name && imageLink + ? ( + ) + : name + } + + + + ); +} + +export default React.memo(Item); diff --git a/packages/page-nfts/src/AccountItems/index.tsx b/packages/page-nfts/src/AccountItems/index.tsx new file mode 100644 index 000000000000..d2758869d717 --- /dev/null +++ b/packages/page-nfts/src/AccountItems/index.tsx @@ -0,0 +1,104 @@ +// Copyright 2017-2022 @polkadot/app-nfts authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { CollectionInfo, CollectionInfoComplete } from '../types'; + +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import styled from 'styled-components'; + +import { Dropdown, Table } from '@polkadot/react-components'; +import { formatNumber } from '@polkadot/util'; + +import { useTranslation } from '../translate'; +import Item from './Item'; +import useAccountItems from './useAccountItems'; +import useItemsInfos from './useItemsInfos'; + +interface Props { + className?: string; + infos?: CollectionInfo[]; +} + +function AccountItems ({ className, infos = [] }: Props): React.ReactElement { + const { t } = useTranslation(); + const NO_NAME = ` - ${t('no name')} -`; + + const [infoIndex, setInfoIndex] = useState(0); + const [info, setInfo] = useState(null); + const accountItems = useAccountItems(); + + const collectionItems = useMemo( + () => !info || !accountItems + ? [] + : accountItems.filter(({ collectionId }) => collectionId.eq(info.id)) + , [info, accountItems] + ); + + const itemsInfos = useItemsInfos(collectionItems); + const collectionName = info?.ipfsData?.name || NO_NAME; + + const completeInfos = useMemo( + () => !accountItems + ? [] + : infos + .filter((i): i is CollectionInfoComplete => !!(i.details && i.metadata) && accountItems.some(({ collectionId }) => i.id.eq(collectionId))) + .sort((a, b) => a.id.cmp(b.id)), + [infos, accountItems] + ); + + const collectionOptions = useMemo( + () => completeInfos.map(({ id, ipfsData }, index) => ({ + text: `${ipfsData?.name || NO_NAME} (ID: ${formatNumber(id)})`, + value: index + })), + [completeInfos, NO_NAME] + ); + + const headerRef = useRef([ + [t('items'), 'start', 2], + [t('owner'), 'address media--1000'] + ]); + + useEffect((): void => { + setInfo(() => + infoIndex >= 0 && infoIndex < completeInfos.length + ? completeInfos[infoIndex] + : null + ); + }, [completeInfos, infoIndex]); + + return ( +
+ ('No accounts with items found for the collection')} + filter={collectionOptions.length + ? ( + ('the collection to query for items')} + onChange={setInfoIndex} + options={collectionOptions} + value={infoIndex} + /> + ) + : undefined + } + header={headerRef.current} + > + {itemsInfos && itemsInfos.map((info) => ( + + ))} +
+
+ ); +} + +export default React.memo(styled(AccountItems)` + table { + overflow: auto; + } +`); diff --git a/packages/page-nfts/src/AccountItems/types.ts b/packages/page-nfts/src/AccountItems/types.ts new file mode 100644 index 000000000000..ed42d611de64 --- /dev/null +++ b/packages/page-nfts/src/AccountItems/types.ts @@ -0,0 +1,19 @@ +// Copyright 2017-2022 @polkadot/app-nfts authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { AccountId } from '@polkadot/types/interfaces'; +import type { PalletUniquesInstanceMetadata } from '@polkadot/types/lookup'; +import type { BN } from '@polkadot/util'; + +export interface ItemSupportedIpfsData { + name: string | null; + image: string | null; +} + +export interface ItemInfo { + account: AccountId, + id: BN; + key: string; + metadata: PalletUniquesInstanceMetadata | null; + ipfsData: ItemSupportedIpfsData | null; +} diff --git a/packages/page-nfts/src/AccountItems/useAccountItems.ts b/packages/page-nfts/src/AccountItems/useAccountItems.ts new file mode 100644 index 000000000000..52885a0ed912 --- /dev/null +++ b/packages/page-nfts/src/AccountItems/useAccountItems.ts @@ -0,0 +1,47 @@ +// Copyright 2017-2022 @polkadot/app-nfts authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { StorageKey, u32 } from '@polkadot/types'; +import type { AccountId32 } from '@polkadot/types/interfaces'; +import type { AccountItem } from '../types'; + +import { useEffect, useState } from 'react'; + +import { createNamedHook, useAccounts, useApi, useIsMountedRef } from '@polkadot/react-hooks'; + +function transformResults (results: StorageKey<[AccountId32, u32, u32]>[][]): AccountItem[] { + return results + .filter((r) => !!r.length) + .map((r) => r.map((item) => { + const [accountId, collectionId, itemId] = item.args; + + return { + accountId, + collectionId, + itemId + }; + })) + .flat(); +} + +function useAccountItemsImpl (): AccountItem[] | undefined { + const mountedRef = useIsMountedRef(); + const { api } = useApi(); + const { allAccounts } = useAccounts(); + + const [state, setState] = useState(); + + useEffect((): void => { + if (!allAccounts.length) return; + + const promises = allAccounts.map((account) => api.query.uniques.account.keys(account)); + + Promise.all(promises) + .then((results) => mountedRef.current && setState(transformResults(results))) + .catch(console.error); + }, [allAccounts, api.query.uniques.account, mountedRef]); + + return state; +} + +export default createNamedHook('useAccountItems', useAccountItemsImpl); diff --git a/packages/page-nfts/src/AccountItems/useItemsInfos.ts b/packages/page-nfts/src/AccountItems/useItemsInfos.ts new file mode 100644 index 000000000000..a1a95d15578b --- /dev/null +++ b/packages/page-nfts/src/AccountItems/useItemsInfos.ts @@ -0,0 +1,95 @@ +// Copyright 2017-2022 @polkadot/app-nfts authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { PalletUniquesInstanceMetadata } from '@polkadot/types/lookup'; +import type { BN } from '@polkadot/util'; +import type { AccountItem } from '../types'; +import type { ItemInfo, ItemSupportedIpfsData } from './types'; + +import { useEffect, useMemo, useState } from 'react'; + +import { createNamedHook, useApi, useCall, useIpfsFetch } from '@polkadot/react-hooks'; + +type IpfsData = Map; + +const QUERY_OPTS = { withParams: true }; + +const IPFS_FETCH_OPTIONS = { + transform: (data: string | undefined): ItemSupportedIpfsData | null => { + if (!data) return null; + + try { + const result = JSON.parse(data) as {[key: string]: any}; + + if (result && typeof result === 'object') { + return { + image: typeof result.image === 'string' ? result.image.replace(/ipfs:\/\/|ipfs\//gi, '') : null, + name: typeof result.name === 'string' ? result.name : null + }; + } + } catch {} + + return null; + } +}; + +function extractInfo ([, itemId]: [BN, BN], metadata: PalletUniquesInstanceMetadata, accountItems: AccountItem[]): ItemInfo { + const { accountId } = accountItems.find(({ itemId: _itemId }) => _itemId.eq(itemId)) as AccountItem; + + return { + account: accountId, + id: itemId, + ipfsData: null, + key: itemId.toString(), + metadata: metadata.isEmpty + ? null + : metadata + }; +} + +const addIpfsData = (ipfsData: IpfsData) => (itemInfo: ItemInfo): ItemInfo => { + const ipfsHash = itemInfo.metadata?.toHuman()?.data?.toString() || ''; + + return { + ...itemInfo, + ipfsData: ipfsData.has(ipfsHash) ? ipfsData.get(ipfsHash) as ItemSupportedIpfsData | null : null + }; +}; + +function useItemsInfosImpl (accountItems: AccountItem[]): ItemInfo[] | undefined { + const { api } = useApi(); + const [state, setState] = useState(); + + const ids = useMemo( + () => accountItems.map(({ collectionId, itemId }) => [collectionId, itemId]), + [accountItems] + ); + + const metadata = useCall<[[[BN, BN][]], PalletUniquesInstanceMetadata[]]>(api.query.uniques.instanceMetadataOf.multi, [ids], QUERY_OPTS); + + const ipfsHashes = useMemo((): string[] | undefined => { + if (metadata && metadata[1].length) { + return metadata[1].map((metadataItem) => metadataItem.toHuman()?.data?.toString() || ''); + } + + return undefined; + }, [metadata]); + + const ipfsData = useIpfsFetch(ipfsHashes, IPFS_FETCH_OPTIONS); + + useEffect((): void => { + if (ipfsData && accountItems.length && metadata && metadata[0][0].length) { + const [collectionId] = metadata[0][0][0]; + + if (!collectionId.eq(ids[0][0])) return; + + const itemsInfos = metadata[0][0].map((id, index) => extractInfo(id, metadata[1][index], accountItems)); + + setState(itemsInfos.map(addIpfsData(ipfsData))); + } + }, [accountItems, ids, ipfsData, metadata]); + + return state; +} + +export default createNamedHook('useItemsInfos', useItemsInfosImpl); diff --git a/packages/page-nfts/src/Overview/Collection.tsx b/packages/page-nfts/src/Overview/Collection.tsx new file mode 100644 index 000000000000..c0d643dc39d4 --- /dev/null +++ b/packages/page-nfts/src/Overview/Collection.tsx @@ -0,0 +1,46 @@ +// Copyright 2017-2022 @polkadot/app-nfts authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { CollectionInfo } from '../types'; + +import React from 'react'; + +import { AddressSmall, IconLink } from '@polkadot/react-components'; +import { formatNumber } from '@polkadot/util'; + +import { useTranslation } from '../translate'; + +interface Props { + className?: string; + value: CollectionInfo; +} + +function Collection ({ className, value: { details, id, ipfsData } }: Props): React.ReactElement { + const { t } = useTranslation(); + const name = ipfsData?.name || ''; + const imageLink = ipfsData?.image ? `https://ipfs.io/ipfs/${ipfsData.image}` : ''; + + return ( + +

{formatNumber(id)}

+ + { name && imageLink + ? ( + ) + : name + } + + {details && } + {details?.toJSON()?.isFrozen && t('Frozen')} + {details?.instances && formatNumber(details.instances)} + + ); +} + +export default React.memo(Collection); diff --git a/packages/page-nfts/src/Overview/Collections.tsx b/packages/page-nfts/src/Overview/Collections.tsx new file mode 100644 index 000000000000..8780cb77bc8f --- /dev/null +++ b/packages/page-nfts/src/Overview/Collections.tsx @@ -0,0 +1,44 @@ +// Copyright 2017-2022 @polkadot/app-nfts authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { CollectionInfo } from '../types'; + +import React, { useRef } from 'react'; + +import { Table } from '@polkadot/react-components'; + +import { useTranslation } from '../translate'; +import Collection from './Collection'; + +interface Props { + className?: string; + infos?: CollectionInfo[]; +} + +function Collections ({ className, infos }: Props): React.ReactElement { + const { t } = useTranslation(); + + const headerRef = useRef([ + [t('collections'), 'start', 2], + [t('owner'), 'address media--1000'], + [t('status')], + [t('items')] + ]); + + return ( + ('No collections found')} + header={headerRef.current} + > + {infos?.map((info) => ( + + ))} +
+ ); +} + +export default React.memo(Collections); diff --git a/packages/page-nfts/src/Overview/Summary.tsx b/packages/page-nfts/src/Overview/Summary.tsx new file mode 100644 index 000000000000..7f940c912b2d --- /dev/null +++ b/packages/page-nfts/src/Overview/Summary.tsx @@ -0,0 +1,28 @@ +// Copyright 2017-2022 @polkadot/app-nfts authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import { CardSummary, SummaryBox } from '@polkadot/react-components'; +import { formatNumber } from '@polkadot/util'; + +import { useTranslation } from '../translate'; + +interface Props { + className?: string; + numCollections?: number; +} + +function Summary ({ className, numCollections }: Props): React.ReactElement { + const { t } = useTranslation(); + + return ( + + ('collections')}> + {formatNumber(numCollections)} + + + ); +} + +export default React.memo(Summary); diff --git a/packages/page-nfts/src/Overview/index.tsx b/packages/page-nfts/src/Overview/index.tsx new file mode 100644 index 000000000000..473c23af41f0 --- /dev/null +++ b/packages/page-nfts/src/Overview/index.tsx @@ -0,0 +1,27 @@ +// Copyright 2017-2022 @polkadot/app-nfts authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { BN } from '@polkadot/util'; +import type { CollectionInfo } from '../types'; + +import React from 'react'; + +import Collections from './Collections'; +import Summary from './Summary'; + +interface Props { + className?: string; + ids?: BN[]; + infos?: CollectionInfo[]; +} + +function Overview ({ className, ids, infos }: Props): React.ReactElement { + return ( +
+ + +
+ ); +} + +export default React.memo(Overview); diff --git a/packages/page-nfts/src/index.tsx b/packages/page-nfts/src/index.tsx new file mode 100644 index 000000000000..249271c03b98 --- /dev/null +++ b/packages/page-nfts/src/index.tsx @@ -0,0 +1,70 @@ +// Copyright 2017-2022 @polkadot/app-nfts authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import '@polkadot/api-augment/substrate'; + +import React, { useMemo, useRef } from 'react'; +import { Route, Switch } from 'react-router'; + +import { Tabs } from '@polkadot/react-components'; +import { useAccounts } from '@polkadot/react-hooks'; + +import AccountItems from './AccountItems'; +import Overview from './Overview'; +import { useTranslation } from './translate'; +import useCollectionIds from './useCollectionIds'; +import useCollectionInfos from './useCollectionInfos'; + +interface Props { + basePath: string; + className?: string; +} + +function NftApp ({ basePath, className }: Props): React.ReactElement { + const { t } = useTranslation(); + const { hasAccounts } = useAccounts(); + const ids = useCollectionIds(); + const infos = useCollectionInfos(ids); + + const tabsRef = useRef([ + { + isRoot: true, + name: 'overview', + text: t('Overview') + }, + { + name: 'my-nfts', + text: t('My NFTs') + } + ]); + + const hidden = useMemo( + () => (hasAccounts && infos && infos.some(({ details, metadata }) => !!(details && metadata))) + ? [] + : ['my-nfts'], + [hasAccounts, infos] + ); + + return ( +
+
+ ); +} + +export default React.memo(NftApp); diff --git a/packages/page-nfts/src/translate.ts b/packages/page-nfts/src/translate.ts new file mode 100644 index 000000000000..ac01013c5c05 --- /dev/null +++ b/packages/page-nfts/src/translate.ts @@ -0,0 +1,10 @@ +// Copyright 2017-2022 @polkadot/app-nfts authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { UseTranslationResponse } from 'react-i18next'; + +import { useTranslation as useTranslationBase } from 'react-i18next'; + +export function useTranslation (): UseTranslationResponse<'app-nfts', undefined> { + return useTranslationBase('app-nfts'); +} diff --git a/packages/page-nfts/src/types.ts b/packages/page-nfts/src/types.ts new file mode 100644 index 000000000000..33c6682dcf8c --- /dev/null +++ b/packages/page-nfts/src/types.ts @@ -0,0 +1,34 @@ +// Copyright 2017-2022 @polkadot/app-nfts authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { AccountId } from '@polkadot/types/interfaces'; +import type { PalletUniquesClassDetails, PalletUniquesClassMetadata } from '@polkadot/types/lookup'; +import type { BN } from '@polkadot/util'; + +export interface CollectionSupportedIpfsData { + name: string | null; + image: string | null; +} + +export interface CollectionInfo { + details: PalletUniquesClassDetails | null; + id: BN; + isAdminMe: boolean; + isIssuerMe: boolean; + isFreezerMe: boolean; + isOwnerMe: boolean; + key: string; + metadata: PalletUniquesClassMetadata | null; + ipfsData: CollectionSupportedIpfsData | null; +} + +export interface CollectionInfoComplete extends CollectionInfo { + details: PalletUniquesClassDetails; + metadata: PalletUniquesClassMetadata; +} + +export interface AccountItem { + accountId: AccountId; + collectionId: BN; + itemId: BN; +} diff --git a/packages/page-nfts/src/useCollectionIds.ts b/packages/page-nfts/src/useCollectionIds.ts new file mode 100644 index 000000000000..bd19c0940bec --- /dev/null +++ b/packages/page-nfts/src/useCollectionIds.ts @@ -0,0 +1,26 @@ +// Copyright 2017-2022 @polkadot/app-nfts authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { StorageKey } from '@polkadot/types'; +import type { AssetId } from '@polkadot/types/interfaces'; + +import { createNamedHook, useApi, useEventTrigger, useMapKeys } from '@polkadot/react-hooks'; + +const options = { + transform: (keys: StorageKey<[AssetId]>[]): AssetId[] => + keys + .map(({ args: [assetId] }) => assetId) + .sort((a, b) => a.cmp(b)) +}; + +function useCollectionIdsImpl (): AssetId[] | undefined { + const { api } = useApi(); + const trigger = useEventTrigger([ + api.events.uniques.Created, + api.events.uniques.Destroyed + ]); + + return useMapKeys(api.query.uniques.class, options, trigger.blockHash); +} + +export default createNamedHook('useCollectionIds', useCollectionIdsImpl); diff --git a/packages/page-nfts/src/useCollectionInfos.ts b/packages/page-nfts/src/useCollectionInfos.ts new file mode 100644 index 000000000000..5b607639f4f2 --- /dev/null +++ b/packages/page-nfts/src/useCollectionInfos.ts @@ -0,0 +1,114 @@ +// Copyright 2017-2022 @polkadot/app-nfts authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Option } from '@polkadot/types'; +import type { AccountId } from '@polkadot/types/interfaces'; +import type { PalletUniquesClassDetails, PalletUniquesClassMetadata } from '@polkadot/types/lookup'; +import type { BN } from '@polkadot/util'; +import type { CollectionInfo } from './types'; + +import { useEffect, useMemo, useState } from 'react'; + +import { createNamedHook, useAccounts, useApi, useCall, useIpfsFetch } from '@polkadot/react-hooks'; + +import { CollectionSupportedIpfsData } from './types'; + +type IpfsData = Map; + +const EMPTY_FLAGS = { + isAdminMe: false, + isFreezerMe: false, + isIssuerMe: false, + isOwnerMe: false +}; + +const QUERY_OPTS = { withParams: true }; + +const IPFS_FETCH_OPTIONS = { + transform: (data: string | undefined): CollectionSupportedIpfsData | null => { + if (!data) return null; + + try { + const result = JSON.parse(data) as {[key: string]: any}; + + if (result && typeof result === 'object') { + return { + image: typeof result.image === 'string' ? result.image.replace(/ipfs:\/\/|ipfs\//gi, '') : null, + name: typeof result.name === 'string' ? result.name : null + }; + } + } catch {} + + return null; + } +}; + +function isAccount (allAccounts: string[], accountId: AccountId): boolean { + const address = accountId.toString(); + + return allAccounts.some((a) => a === address); +} + +function extractInfo (allAccounts: string[], id: BN, optDetails: Option, metadata: PalletUniquesClassMetadata): CollectionInfo { + const details = optDetails.unwrapOr(null); + + return { + ...(details + ? { + isAdminMe: isAccount(allAccounts, details.admin), + isFreezerMe: isAccount(allAccounts, details.freezer), + isIssuerMe: isAccount(allAccounts, details.issuer), + isOwnerMe: isAccount(allAccounts, details.owner) + } + : EMPTY_FLAGS + ), + details, + id, + ipfsData: null, + key: id.toString(), + metadata: metadata.isEmpty + ? null + : metadata + }; +} + +const addIpfsData = (ipfsData: IpfsData) => (collectionInfo: CollectionInfo): CollectionInfo => { + const ipfsHash = collectionInfo.metadata?.toHuman()?.data?.toString() || ''; + + return { + ...collectionInfo, + ipfsData: ipfsData.has(ipfsHash) ? ipfsData.get(ipfsHash) as CollectionSupportedIpfsData | null : null + }; +}; + +function useCollectionInfosImpl (ids?: BN[]): CollectionInfo[] | undefined { + const { api } = useApi(); + const { allAccounts } = useAccounts(); + const metadata = useCall<[[BN[]], PalletUniquesClassMetadata[]]>(api.query.uniques.classMetadataOf.multi, [ids], QUERY_OPTS); + const details = useCall<[[BN[]], Option[]]>(api.query.uniques.class.multi, [ids], QUERY_OPTS); + const [state, setState] = useState(); + + const ipfsHashes = useMemo((): string[] | undefined => { + if (metadata && metadata[1].length) { + return metadata[1].map((metadataItem) => metadataItem.toHuman()?.data?.toString() || ''); + } + + return undefined; + }, [metadata]); + + const ipfsData = useIpfsFetch(ipfsHashes, IPFS_FETCH_OPTIONS); + + useEffect((): void => { + if (ipfsData && details && metadata && (details[0][0].length === metadata[0][0].length)) { + const collectionInfos = details[0][0].map((id, index) => + extractInfo(allAccounts, id, details[1][index], metadata[1][index]) + ); + + setState(collectionInfos.map(addIpfsData(ipfsData))); + } + }, [allAccounts, details, ids, ipfsData, metadata]); + + return state; +} + +export default createNamedHook('useCollectionInfos', useCollectionInfosImpl); diff --git a/packages/page-nfts/tsconfig.build.json b/packages/page-nfts/tsconfig.build.json new file mode 100644 index 000000000000..da24d9f22599 --- /dev/null +++ b/packages/page-nfts/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "..", + "outDir": "./build", + "rootDir": "./src" + }, + "references": [] +} diff --git a/packages/react-hooks/package.json b/packages/react-hooks/package.json index 89b1108c1464..fdc9c1b150cf 100644 --- a/packages/react-hooks/package.json +++ b/packages/react-hooks/package.json @@ -14,6 +14,7 @@ "version": "0.109.2-3", "dependencies": { "@babel/runtime": "^7.17.2", - "@polkadot/hw-ledger": "^8.5.1" + "@polkadot/hw-ledger": "^8.5.1", + "is-ipfs": "6.0.2" } } diff --git a/packages/react-hooks/src/index.ts b/packages/react-hooks/src/index.ts index ca8f1dfc5487..82cca3e3e202 100644 --- a/packages/react-hooks/src/index.ts +++ b/packages/react-hooks/src/index.ts @@ -33,6 +33,7 @@ export { useFormField } from './useFormField'; export { useIncrement } from './useIncrement'; export { useInflation } from './useInflation'; export { useIpfs } from './useIpfs'; +export { useIpfsFetch } from './useIpfsFetch'; export { useIsMountedRef } from './useIsMountedRef'; export { useJudgements } from './useJudgements'; export { useLedger } from './useLedger'; diff --git a/packages/react-hooks/src/useIpfsFetch.ts b/packages/react-hooks/src/useIpfsFetch.ts new file mode 100644 index 000000000000..d09668dc803e --- /dev/null +++ b/packages/react-hooks/src/useIpfsFetch.ts @@ -0,0 +1,77 @@ +// Copyright 2017-2022 @polkadot/react-hooks authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import '@polkadot/x-textencoder/shim'; +import '@polkadot/x-textdecoder/shim'; + +import type { CallOptions } from './types'; + +import isIPFS from 'is-ipfs'; +import { useEffect, useMemo, useState } from 'react'; + +import { useIsMountedRef } from './useIsMountedRef'; + +function isCid (cid: string): boolean { + return !!cid && (isIPFS.cid(cid) || isIPFS.base32cid(cid.toLowerCase())); +} + +const cache = new Map(); + +async function fetchIpfsData (ipfsHashes: string[]): Promise> { + const result = new Map(); + + const promises = ipfsHashes.map((ipfsHash) => { + if (cache.has(ipfsHash)) { + result.set(ipfsHash, cache.get(ipfsHash)); + + return Promise.resolve(); + } + + return fetch(`https://ipfs.io/ipfs/${ipfsHash}`) + .then(async (res) => { + const ipfsResponse = res.status >= 200 && res.status < 300 ? await res.text() : null; + + cache.set(ipfsHash, ipfsResponse); + result.set(ipfsHash, ipfsResponse); + }); + }); + + await Promise.allSettled(promises); + + return result; +} + +function postProcessData (ipfsData: Map, options?: CallOptions) { + if (!options?.transform) return ipfsData; + + for (const [key, value] of ipfsData.entries()) { + ipfsData.set(key, options?.transform(value)); + } + + return ipfsData; +} + +// FIXME This is generic, we cannot really use createNamedHook +export function useIpfsFetch (hashes: string[] | undefined, options?: CallOptions): Map | undefined { + const mountedRef = useIsMountedRef(); + const [value, setValue] = useState | undefined>(); + + const ipfsHashes = useMemo(() => { + if (!hashes) return undefined; + + return hashes + .map((hash) => isCid(hash) ? hash : '') + .filter((hash) => !!hash); + }, [hashes]); + + useEffect((): void => { + if (mountedRef.current && ipfsHashes) { + fetchIpfsData(ipfsHashes) + .then((ipfsData) => setValue(postProcessData(ipfsData, options))) + // eslint-disable-next-line @typescript-eslint/no-empty-function + .catch(() => { }); + } + }, [ipfsHashes, options, mountedRef]); + + return value; +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 96c28ab54c72..431b12f2140b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -42,6 +42,8 @@ "@polkadot/app-js/*": ["page-js/src/*"], "@polkadot/app-membership": ["page-membership/src"], "@polkadot/app-membership/*": ["page-membership/src/*"], + "@polkadot/app-nfts": ["page-nfts/src"], + "@polkadot/app-nfts/*": ["page-nfts/src/*"], "@polkadot/app-parachains": ["page-parachains/src"], "@polkadot/app-parachains/*": ["page-parachains/src/*"], "@polkadot/app-poll": ["page-poll/src"], diff --git a/yarn.lock b/yarn.lock index e309eaafc0a3..2b617f4977ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2813,6 +2813,15 @@ __metadata: languageName: unknown linkType: soft +"@polkadot/app-nfts@workspace:packages/page-nfts": + version: 0.0.0-use.local + resolution: "@polkadot/app-nfts@workspace:packages/page-nfts" + dependencies: + "@babel/runtime": ^7.17.2 + "@polkadot/react-components": ^0.109.2-3 + languageName: unknown + linkType: soft + "@polkadot/app-parachains@workspace:packages/page-parachains": version: 0.0.0-use.local resolution: "@polkadot/app-parachains@workspace:packages/page-parachains" @@ -3308,6 +3317,7 @@ __metadata: dependencies: "@babel/runtime": ^7.17.2 "@polkadot/hw-ledger": ^8.5.1 + is-ipfs: 6.0.2 languageName: unknown linkType: soft @@ -8644,6 +8654,17 @@ __metadata: languageName: node linkType: hard +"dns-over-http-resolver@npm:^1.2.3": + version: 1.2.3 + resolution: "dns-over-http-resolver@npm:1.2.3" + dependencies: + debug: ^4.3.1 + native-fetch: ^3.0.0 + receptacle: ^1.3.2 + checksum: 3cc1a1d77fc43e7a8a12453da987b80860ac96dc1031386c5eb1a39154775a87cfa1d50c0eaa5ea5e397e898791654608f6e2acf03f750f4098ab8822bb7d928 + languageName: node + linkType: hard + "dns-packet@npm:^1.3.1": version: 1.3.1 resolution: "dns-packet@npm:1.3.1" @@ -9230,6 +9251,13 @@ __metadata: languageName: node linkType: hard +"err-code@npm:^3.0.1": + version: 3.0.1 + resolution: "err-code@npm:3.0.1" + checksum: aede1f1d5ebe6d6b30b5e3175e3cc13e67de2e2e1ad99ce4917e957d7b59e8451ed10ee37dbc6493521920a47082c479b9097e5c39438d4aff4cc84438568a5a + languageName: node + linkType: hard + "error-ex@npm:^1.3.1": version: 1.3.2 resolution: "error-ex@npm:1.3.2" @@ -12248,6 +12276,19 @@ __metadata: languageName: node linkType: hard +"is-ipfs@npm:6.0.2": + version: 6.0.2 + resolution: "is-ipfs@npm:6.0.2" + dependencies: + iso-url: ^1.1.3 + mafmt: ^10.0.0 + multiaddr: ^10.0.0 + multiformats: ^9.0.0 + uint8arrays: ^3.0.0 + checksum: 9397057264c61e381e3f24878b8d6d93aed68e5601a0a27ae998820beb92bb906c3ebba22449b0ec234db2d8e3429aabbc7372c692bbf55294e982413ab56c59 + languageName: node + linkType: hard + "is-ipfs@npm:^0.6.0": version: 0.6.3 resolution: "is-ipfs@npm:0.6.3" @@ -12614,6 +12655,13 @@ __metadata: languageName: node linkType: hard +"iso-url@npm:^1.1.3": + version: 1.2.1 + resolution: "iso-url@npm:1.2.1" + checksum: 1af98c4ed6a39598407fd8c3c13e997c978985f477af2be3390d2aa3e422b4b5992ffbb0dac68656b165c71850fff748ac1309d29d4f2a728707d76bf0f98557 + languageName: node + linkType: hard + "isobject@npm:^3.0.1": version: 3.0.1 resolution: "isobject@npm:3.0.1" @@ -13951,6 +13999,15 @@ __metadata: languageName: node linkType: hard +"mafmt@npm:^10.0.0": + version: 10.0.0 + resolution: "mafmt@npm:10.0.0" + dependencies: + multiaddr: ^10.0.0 + checksum: ab9aef1e429870f7ef7fd141d8dc139b1bca9d145196913a84e1acee7b4eb2b7c9e1865d6c928e3d84008a6e735666c6ae3d4b51a554bcb64d566ea12607bedb + languageName: node + linkType: hard + "mafmt@npm:^7.0.0": version: 7.1.0 resolution: "mafmt@npm:7.1.0" @@ -14690,6 +14747,20 @@ __metadata: languageName: node linkType: hard +"multiaddr@npm:^10.0.0": + version: 10.0.1 + resolution: "multiaddr@npm:10.0.1" + dependencies: + dns-over-http-resolver: ^1.2.3 + err-code: ^3.0.1 + is-ip: ^3.1.0 + multiformats: ^9.4.5 + uint8arrays: ^3.0.0 + varint: ^6.0.0 + checksum: d53aaf7efd52ee5e6413ef36ececd29239ceb5c1f048c1fa9b820442226dc232067312d25e509a2571a14047465fb934dd35029c7f3166f4d02d13e3c501925d + languageName: node + linkType: hard + "multiaddr@npm:^7.2.1, multiaddr@npm:^7.3.0": version: 7.5.0 resolution: "multiaddr@npm:7.5.0" @@ -14772,6 +14843,13 @@ __metadata: languageName: node linkType: hard +"multiformats@npm:^9.0.0, multiformats@npm:^9.4.2, multiformats@npm:^9.4.5": + version: 9.6.4 + resolution: "multiformats@npm:9.6.4" + checksum: b3b8e481112379d6f3aace199a06e6974dfe3ed4e2100a0effe19fef936ba31704382356df6278a3922b5cb47fcfefe3e5bc54e5bebef35272e3503bad3c62ba + languageName: node + linkType: hard + "multihashes@npm:^0.4.15, multihashes@npm:~0.4.13, multihashes@npm:~0.4.15": version: 0.4.21 resolution: "multihashes@npm:0.4.21" @@ -14833,6 +14911,15 @@ __metadata: languageName: node linkType: hard +"native-fetch@npm:^3.0.0": + version: 3.0.0 + resolution: "native-fetch@npm:3.0.0" + peerDependencies: + node-fetch: "*" + checksum: eec8cc78d6da4d0f3f56055e3e557473ac86dd35fd40053ea268d644af7b20babc891d2b53ef821b77ed2428265f60b85e49d754c555de89bfa071a743b853bb + languageName: node + linkType: hard + "natural-compare-lite@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare-lite@npm:1.4.0" @@ -16847,6 +16934,15 @@ __metadata: languageName: node linkType: hard +"receptacle@npm:^1.3.2": + version: 1.3.2 + resolution: "receptacle@npm:1.3.2" + dependencies: + ms: ^2.1.1 + checksum: 7c5011f19e6ddcb759c1e6756877cee3c9eb78fbd1278eca4572d75f74993f0ccdc1e5f7761de6e682dff5344ee94f7a69bc492e2e8eb81d8777774a2399ce9c + languageName: node + linkType: hard + "rechoir@npm:^0.6.2": version: 0.6.2 resolution: "rechoir@npm:0.6.2" @@ -19432,6 +19528,15 @@ resolve@^2.0.0-next.3: languageName: node linkType: hard +"uint8arrays@npm:^3.0.0": + version: 3.0.0 + resolution: "uint8arrays@npm:3.0.0" + dependencies: + multiformats: ^9.4.2 + checksum: 58470e687140e64a7fa08ab66b64777b75f105bf78180324448dc798436beacf0bd322cd2b58d20ca4cfa2e091f58e4b52d008e95f21d0ade16c1102b5d23ad3 + languageName: node + linkType: hard + "ultron@npm:~1.1.0": version: 1.1.1 resolution: "ultron@npm:1.1.1" @@ -19876,6 +19981,13 @@ resolve@^2.0.0-next.3: languageName: node linkType: hard +"varint@npm:^6.0.0": + version: 6.0.0 + resolution: "varint@npm:6.0.0" + checksum: 7684113c9d497c01e40396e50169c502eb2176203219b96e1c5ac965a3e15b4892bd22b7e48d87148e10fffe638130516b6dbeedd0efde2b2d0395aa1772eea7 + languageName: node + linkType: hard + "vary@npm:^1, vary@npm:~1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2"