From 032606e61c74e9d5b7eca415c5fa62f8d27eaa04 Mon Sep 17 00:00:00 2001 From: Matthias Kretschmann Date: Mon, 13 Sep 2021 16:39:32 +0200 Subject: [PATCH] add total user liquidity, new Profile provider (#841) * label renaming, add total user liquidity * new useProfile provider * centralize pool shares fetching * add some assets fetching to profile provider * move 3box profile fetching, check passed accountId * cancel token fixes * remove publisher on published assets list * more cancel token fixes * prevent asset name double fetching in pool shares * prevent asset name double fetching in downloads * prevent asset name double fetching in pool transactions * more cancel token fixes * refetch crash fix * another pool shares refetch fix * pool transactions data flow refactor * Add total downloads, speed up downloads fetching (#849) * add total downloads * replace multiple retrieveDDO with one single request * getAssetsFromDidList() helper * fix mixed up timestamps * data structure based on tokenOrders * add logging * add tooltip to downloads, small NumberUnit refactor * safeguard against passed empty didList * deal with plural/singular in labels --- src/components/atoms/Tooltip.module.css | 4 + src/components/atoms/Tooltip.tsx | 1 + src/components/molecules/AssetListTitle.tsx | 3 +- src/components/molecules/AssetTeaser.tsx | 8 +- src/components/molecules/Bookmarks.tsx | 34 +- .../molecules/NumberUnit.module.css | 7 + src/components/molecules/NumberUnit.tsx | 45 ++- .../molecules/PoolTransactions/Title.tsx | 1 + .../molecules/PoolTransactions/index.tsx | 123 +++++--- src/components/organisms/AssetList.tsx | 5 +- .../pages/Profile/Header/Account.tsx | 19 +- .../pages/Profile/Header/PublisherLinks.tsx | 8 +- .../pages/Profile/Header/Stats.module.css | 4 +- src/components/pages/Profile/Header/Stats.tsx | 108 ++++--- src/components/pages/Profile/Header/index.tsx | 67 +--- .../pages/Profile/History/Downloads.tsx | 106 +------ .../pages/Profile/History/PoolShares.tsx | 116 +++---- .../pages/Profile/History/PublishedList.tsx | 45 ++- src/pages/profile/index.tsx | 5 +- src/providers/Profile.tsx | 291 ++++++++++++++++++ src/utils/aquarius.ts | 137 ++++++++- src/utils/subgraph.ts | 98 +++++- 22 files changed, 800 insertions(+), 435 deletions(-) create mode 100644 src/providers/Profile.tsx diff --git a/src/components/atoms/Tooltip.module.css b/src/components/atoms/Tooltip.module.css index 97bcbda11..131a7cea5 100644 --- a/src/components/atoms/Tooltip.module.css +++ b/src/components/atoms/Tooltip.module.css @@ -9,6 +9,10 @@ font-size: var(--font-size-small); } +.content p { + margin: 0; +} + .icon { width: 1em; height: 1em; diff --git a/src/components/atoms/Tooltip.tsx b/src/components/atoms/Tooltip.tsx index 1719ff0a0..08a5039a2 100644 --- a/src/components/atoms/Tooltip.tsx +++ b/src/components/atoms/Tooltip.tsx @@ -5,6 +5,7 @@ import { useSpring, animated } from 'react-spring' import styles from './Tooltip.module.css' import { ReactComponent as Info } from '../../images/info.svg' import { Placement } from 'tippy.js' +import Markdown from './Markdown' const cx = classNames.bind(styles) diff --git a/src/components/molecules/AssetListTitle.tsx b/src/components/molecules/AssetListTitle.tsx index 9fb6a7535..fe6c67bfe 100644 --- a/src/components/molecules/AssetListTitle.tsx +++ b/src/components/molecules/AssetListTitle.tsx @@ -1,5 +1,4 @@ import { DDO } from '@oceanprotocol/lib' -import { useOcean } from '../../providers/Ocean' import { Link } from 'gatsby' import React, { ReactElement, useEffect, useState } from 'react' import { getAssetsNames } from '../../utils/aquarius' @@ -43,7 +42,7 @@ export default function AssetListTitle({ return (

- {assetTitle} + {assetTitle}

) } diff --git a/src/components/molecules/AssetTeaser.tsx b/src/components/molecules/AssetTeaser.tsx index 2543e1ba1..b1b249642 100644 --- a/src/components/molecules/AssetTeaser.tsx +++ b/src/components/molecules/AssetTeaser.tsx @@ -13,11 +13,13 @@ import { BestPrice } from '../../models/BestPrice' declare type AssetTeaserProps = { ddo: DDO price: BestPrice + noPublisher?: boolean } const AssetTeaser: React.FC = ({ ddo, - price + price, + noPublisher }: AssetTeaserProps) => { const { attributes } = ddo.findServiceByType('metadata') const { name, type } = attributes.main @@ -34,7 +36,9 @@ const AssetTeaser: React.FC = ({

{name}

- + {!noPublisher && ( + + )} { - if (!appConfig.metadataCacheUri || bookmarks === []) return + if (!appConfig?.metadataCacheUri || bookmarks === []) return const source = axios.CancelToken.source() @@ -121,7 +95,7 @@ export default function Bookmarks(): ReactElement { return () => { source.cancel() } - }, [bookmarks, chainIds]) + }, [bookmarks, chainIds, appConfig?.metadataCacheUri]) return ( ( - <> -
- {icon && icon} - {value} -
- {label} - -) - export default function NumberUnit({ - link, - linkTooltip, small, label, value, - icon + icon, + tooltip }: NumberUnitProps): ReactElement { return (
- {link ? ( - - - - ) : ( - - )} +
+ {icon && icon} + {value} +
+ + {label}{' '} + {tooltip && ( + } + className={styles.tooltip} + /> + )} +
) } diff --git a/src/components/molecules/PoolTransactions/Title.tsx b/src/components/molecules/PoolTransactions/Title.tsx index 8535a44df..bd9fcf5a7 100644 --- a/src/components/molecules/PoolTransactions/Title.tsx +++ b/src/components/molecules/PoolTransactions/Title.tsx @@ -68,6 +68,7 @@ export default function Title({ row }: { row: PoolTransaction }): ReactElement { useEffect(() => { if (!locale || !row) return + async function init() { const title = await getTitle(row, locale) setTitle(title) diff --git a/src/components/molecules/PoolTransactions/index.tsx b/src/components/molecules/PoolTransactions/index.tsx index b3d11fe65..2ba87f53f 100644 --- a/src/components/molecules/PoolTransactions/index.tsx +++ b/src/components/molecules/PoolTransactions/index.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement, useEffect, useState } from 'react' +import React, { ReactElement, useCallback, useEffect, useState } from 'react' import Time from '../../atoms/Time' import Table from '../../atoms/Table' import AssetTitle from '../AssetListTitle' @@ -10,9 +10,10 @@ import { fetchDataForMultipleChains } from '../../../utils/subgraph' import { useSiteMetadata } from '../../../hooks/useSiteMetadata' import NetworkName from '../../atoms/NetworkName' import { retrieveDDO } from '../../../utils/aquarius' -import axios from 'axios' +import axios, { CancelToken } from 'axios' import Title from './Title' import styles from './index.module.css' +import { DDO, Logger } from '@oceanprotocol/lib' const REFETCH_INTERVAL = 20000 @@ -75,6 +76,7 @@ export interface Datatoken { export interface PoolTransaction extends TransactionHistoryPoolTransactions { networkId: number + ddo: DDO } const columns = [ @@ -87,11 +89,7 @@ const columns = [ { name: 'Data Set', selector: function getAssetRow(row: PoolTransaction) { - const did = web3.utils - .toChecksumAddress(row.poolAddress.datatokenAddress) - .replace('0x', 'did:op:') - - return + return } }, { @@ -131,14 +129,14 @@ export default function PoolTransactions({ minimal?: boolean accountId: string }): ReactElement { - const [logs, setLogs] = useState() - const [isLoading, setIsLoading] = useState(false) + const [transactions, setTransactions] = useState() + const [isLoading, setIsLoading] = useState(true) const { chainIds } = useUserPreferences() const { appConfig } = useSiteMetadata() const [dataFetchInterval, setDataFetchInterval] = useState() const [data, setData] = useState() - async function fetchPoolTransactionData() { + const getPoolTransactionData = useCallback(async () => { const variables = { user: accountId?.toLowerCase(), pool: poolAddress?.toLowerCase() @@ -159,71 +157,94 @@ export default function PoolTransactions({ if (JSON.stringify(data) !== JSON.stringify(transactions)) { setData(transactions) } - } + }, [accountId, chainIds, data, poolAddress, poolChainId]) - function refetchPoolTransactions() { - if (!dataFetchInterval) { - setDataFetchInterval( - setInterval(function () { - fetchPoolTransactionData() - }, REFETCH_INTERVAL) + const getPoolTransactions = useCallback( + async (cancelToken: CancelToken) => { + if (!data) return + + const poolTransactions: PoolTransaction[] = [] + + for (let i = 0; i < data.length; i++) { + const { datatokenAddress } = data[i].poolAddress + const did = web3.utils + .toChecksumAddress(datatokenAddress) + .replace('0x', 'did:op:') + const ddo = await retrieveDDO(did, cancelToken) + ddo && + poolTransactions.push({ + ...data[i], + networkId: ddo?.chainId, + ddo + }) + } + const sortedTransactions = poolTransactions.sort( + (a, b) => b.timestamp - a.timestamp ) - } - } + setTransactions(sortedTransactions) + }, + [data] + ) + // + // Get data, periodically + // useEffect(() => { + if (!appConfig?.metadataCacheUri) return + + async function getTransactions() { + try { + await getPoolTransactionData() + + if (dataFetchInterval) return + const interval = setInterval(async () => { + await getPoolTransactionData() + }, REFETCH_INTERVAL) + setDataFetchInterval(interval) + } catch (error) { + Logger.error('Error fetching pool transactions: ', error.message) + } + } + getTransactions() + return () => { clearInterval(dataFetchInterval) } - }, [dataFetchInterval]) + }, [getPoolTransactionData, dataFetchInterval, appConfig.metadataCacheUri]) + // + // Transform to final transactions + // useEffect(() => { - if (!appConfig.metadataCacheUri) return + const cancelTokenSource = axios.CancelToken.source() - async function getTransactions() { - const poolTransactions: PoolTransaction[] = [] - const source = axios.CancelToken.source() + async function transformData() { try { setIsLoading(true) - - if (!data) { - await fetchPoolTransactionData() - return - } - const poolTransactionsData = data.map((obj) => ({ ...obj })) - - for (let i = 0; i < poolTransactionsData.length; i++) { - const did = web3.utils - .toChecksumAddress( - poolTransactionsData[i].poolAddress.datatokenAddress - ) - .replace('0x', 'did:op:') - const ddo = await retrieveDDO(did, source.token) - poolTransactionsData[i].networkId = ddo.chainId - poolTransactions.push(poolTransactionsData[i]) - } - const sortedTransactions = poolTransactions.sort( - (a, b) => b.timestamp - a.timestamp - ) - setLogs(sortedTransactions) - refetchPoolTransactions() + await getPoolTransactions(cancelTokenSource.token) } catch (error) { - console.error('Error fetching pool transactions: ', error.message) + Logger.error('Error fetching pool transactions: ', error.message) } finally { setIsLoading(false) } } - getTransactions() - }, [accountId, chainIds, appConfig.metadataCacheUri, poolAddress, data]) + transformData() + + return () => { + cancelTokenSource.cancel() + } + }, [getPoolTransactions]) return accountId ? (
= 4 : logs?.length >= 9} + pagination={ + minimal ? transactions?.length >= 4 : transactions?.length >= 9 + } paginationPerPage={minimal ? 5 : 10} /> ) : ( diff --git a/src/components/organisms/AssetList.tsx b/src/components/organisms/AssetList.tsx index 477186a3a..219dd3a7b 100644 --- a/src/components/organisms/AssetList.tsx +++ b/src/components/organisms/AssetList.tsx @@ -26,6 +26,7 @@ declare type AssetListProps = { isLoading?: boolean onPageChange?: React.Dispatch> className?: string + noPublisher?: boolean } const AssetList: React.FC = ({ @@ -35,7 +36,8 @@ const AssetList: React.FC = ({ totalPages, isLoading, onPageChange, - className + className, + noPublisher }) => { const { chainIds } = useUserPreferences() const [assetsWithPrices, setAssetWithPrices] = useState() @@ -71,6 +73,7 @@ const AssetList: React.FC = ({ ddo={assetWithPrice.ddo} price={assetWithPrice.price} key={assetWithPrice.ddo.id} + noPublisher={noPublisher} /> )) ) : chainIds.length === 0 ? ( diff --git a/src/components/pages/Profile/Header/Account.tsx b/src/components/pages/Profile/Header/Account.tsx index bcc1abd76..4878a9efc 100644 --- a/src/components/pages/Profile/Header/Account.tsx +++ b/src/components/pages/Profile/Header/Account.tsx @@ -7,23 +7,26 @@ import jellyfish from '@oceanprotocol/art/creatures/jellyfish/jellyfish-grid.svg import Copy from '../../../atoms/Copy' import Blockies from '../../../atoms/Blockies' import styles from './Account.module.css' +import { useProfile } from '../../../../providers/Profile' export default function Account({ - name, - image, accountId }: { - name: string - image: string accountId: string }): ReactElement { const { chainIds } = useUserPreferences() + const { profile } = useProfile() return (
- {image ? ( - + {profile?.image ? ( + ) : accountId ? ( ) : ( @@ -37,7 +40,9 @@ export default function Account({
-

{name || accountTruncate(accountId)}

+

+ {profile?.name || accountTruncate(accountId)} +

{accountId && ( {accountId} diff --git a/src/components/pages/Profile/Header/PublisherLinks.tsx b/src/components/pages/Profile/Header/PublisherLinks.tsx index 01d6028d7..2c97782d7 100644 --- a/src/components/pages/Profile/Header/PublisherLinks.tsx +++ b/src/components/pages/Profile/Header/PublisherLinks.tsx @@ -1,18 +1,18 @@ import React, { ReactElement } from 'react' import classNames from 'classnames/bind' -import { ProfileLink } from '../../../../models/Profile' import { ReactComponent as External } from '../../../../images/external.svg' import styles from './PublisherLinks.module.css' +import { useProfile } from '../../../../providers/Profile' const cx = classNames.bind(styles) export default function PublisherLinks({ - links, className }: { - links: ProfileLink[] className: string }): ReactElement { + const { profile } = useProfile() + const styleClasses = cx({ links: true, [className]: className @@ -21,7 +21,7 @@ export default function PublisherLinks({ return (
{' — '} - {links?.map((link: ProfileLink) => { + {profile?.links?.map((link) => { const href = link.name === 'Twitter' ? `https://twitter.com/${link.value}` diff --git a/src/components/pages/Profile/Header/Stats.module.css b/src/components/pages/Profile/Header/Stats.module.css index 8878438c8..67a6a0c7c 100644 --- a/src/components/pages/Profile/Header/Stats.module.css +++ b/src/components/pages/Profile/Header/Stats.module.css @@ -1,6 +1,6 @@ .stats { display: grid; gap: var(--spacer); - grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); - margin-top: calc(var(--spacer) / 2); + grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); + margin-top: var(--spacer); } diff --git a/src/components/pages/Profile/Header/Stats.tsx b/src/components/pages/Profile/Header/Stats.tsx index c4e5c842e..3c86dbf3c 100644 --- a/src/components/pages/Profile/Header/Stats.tsx +++ b/src/components/pages/Profile/Header/Stats.tsx @@ -1,4 +1,4 @@ -import { DDO, Logger } from '@oceanprotocol/lib' +import { Logger } from '@oceanprotocol/lib' import React, { useEffect, useState } from 'react' import { ReactElement } from 'react-markdown' import { useUserPreferences } from '../../../../providers/UserPreferences' @@ -6,16 +6,27 @@ import { getAccountLiquidityInOwnAssets, getAccountNumberOfOrders, getAssetsBestPrices, - UserTVL + UserLiquidity, + calculateUserLiquidity } from '../../../../utils/subgraph' import Conversion from '../../../atoms/Price/Conversion' import NumberUnit from '../../../molecules/NumberUnit' import styles from './Stats.module.css' -import { - queryMetadata, - transformChainIdsListToQuery -} from '../../../../utils/aquarius' -import axios from 'axios' +import { useProfile } from '../../../../providers/Profile' +import { PoolShares_poolShares as PoolShare } from '../../../../@types/apollo/PoolShares' + +async function getPoolSharesLiquidity( + poolShares: PoolShare[] +): Promise { + let totalLiquidity = 0 + + for (const poolShare of poolShares) { + const poolLiquidity = calculateUserLiquidity(poolShare) + totalLiquidity += poolLiquidity + } + + return totalLiquidity +} export default function Stats({ accountId @@ -23,80 +34,91 @@ export default function Stats({ accountId: string }): ReactElement { const { chainIds } = useUserPreferences() + const { poolShares, assets, assetsTotal, downloadsTotal } = useProfile() - const [publishedAssets, setPublishedAssets] = useState() - const [numberOfAssets, setNumberOfAssets] = useState(0) const [sold, setSold] = useState(0) - const [tvl, setTvl] = useState() + const [publisherLiquidity, setPublisherLiquidity] = useState() + const [totalLiquidity, setTotalLiquidity] = useState(0) useEffect(() => { if (!accountId) { - setNumberOfAssets(0) setSold(0) - setTvl({ price: '0', oceanBalance: '0' }) + setPublisherLiquidity({ price: '0', oceanBalance: '0' }) + setTotalLiquidity(0) return } - async function getPublished() { - const queryPublishedAssets = { - query: { - query_string: { - query: `(publicKey.owner:${accountId}) AND (${transformChainIdsListToQuery( - chainIds - )})` - } - } - } + async function getSales() { + if (!assets) return + try { - const source = axios.CancelToken.source() - const result = await queryMetadata(queryPublishedAssets, source.token) - setPublishedAssets(result.results) - setNumberOfAssets(result.totalResults) - const nrOrders = await getAccountNumberOfOrders( - result.results, - chainIds - ) + const nrOrders = await getAccountNumberOfOrders(assets, chainIds) setSold(nrOrders) } catch (error) { Logger.error(error.message) } } - getPublished() - }, [accountId, chainIds]) + getSales() + }, [accountId, chainIds, assets]) useEffect(() => { - if (!publishedAssets) return + if (!assets || !accountId || !chainIds) return - async function getAccountTVL() { + async function getPublisherLiquidity() { try { const accountPoolAdresses: string[] = [] - const assetsPrices = await getAssetsBestPrices(publishedAssets) + const assetsPrices = await getAssetsBestPrices(assets) for (const priceInfo of assetsPrices) { if (priceInfo.price.type === 'pool') { accountPoolAdresses.push(priceInfo.price.address.toLowerCase()) } } - const userTvl: UserTVL = await getAccountLiquidityInOwnAssets( + const userLiquidity = await getAccountLiquidityInOwnAssets( accountId, chainIds, accountPoolAdresses ) - setTvl(userTvl) + setPublisherLiquidity(userLiquidity) } catch (error) { Logger.error(error.message) } } - getAccountTVL() - }, [publishedAssets, accountId, chainIds]) + getPublisherLiquidity() + }, [assets, accountId, chainIds]) + + useEffect(() => { + if (!poolShares) return + + async function getTotalLiquidity() { + try { + const totalLiquidity = await getPoolSharesLiquidity(poolShares) + setTotalLiquidity(totalLiquidity) + } catch (error) { + console.error('Error fetching pool shares: ', error.message) + } + } + getTotalLiquidity() + }, [poolShares]) return (
} + label="Liquidity in Own Assets" + value={ + + } + /> + } + /> + + + - -
) } diff --git a/src/components/pages/Profile/Header/index.tsx b/src/components/pages/Profile/Header/index.tsx index eaf16c411..42fb0370c 100644 --- a/src/components/pages/Profile/Header/index.tsx +++ b/src/components/pages/Profile/Header/index.tsx @@ -1,26 +1,16 @@ -import React, { ReactElement, useEffect, useState } from 'react' -import get3BoxProfile from '../../../../utils/profile' -import { Profile } from '../../../../models/Profile' -import { accountTruncate } from '../../../../utils/web3' -import axios from 'axios' +import React, { ReactElement, useState } from 'react' import PublisherLinks from './PublisherLinks' import Markdown from '../../../atoms/Markdown' import Stats from './Stats' import Account from './Account' import styles from './index.module.css' +import { useProfile } from '../../../../providers/Profile' const isDescriptionTextClamped = () => { const el = document.getElementById('description') if (el) return el.scrollHeight > el.clientHeight } -const clearedProfile: Profile = { - name: null, - image: null, - description: null, - links: null -} - const Link3Box = ({ accountId, text }: { accountId: string; text: string }) => { return ( ({ - name: accountTruncate(accountId), - image: null, - description: null, - links: null - }) + const { profile } = useProfile() const [isShowMore, setIsShowMore] = useState(false) const toogleShowMore = () => { setIsShowMore(!isShowMore) } - useEffect(() => { - if (!accountId) { - setProfile(clearedProfile) - return - } - - const source = axios.CancelToken.source() - - async function getInfoFrom3Box() { - const profile3Box = await get3BoxProfile(accountId, source.token) - if (profile3Box) { - const { name, emoji, description, image, links } = profile3Box - const newName = `${emoji || ''} ${name || accountTruncate(accountId)}` - const newProfile = { - name: newName, - image, - description, - links - } - setProfile(newProfile) - } else { - setProfile(clearedProfile) - } - } - getInfoFrom3Box() - - return () => { - source.cancel() - } - }, [accountId]) - return (
- +
- + {isDescriptionTextClamped() ? ( @@ -101,11 +51,8 @@ export default function AccountHeader({ ) : ( '' )} - {profile.links?.length > 0 && ( - + {profile?.links?.length > 0 && ( + )}
diff --git a/src/components/pages/Profile/History/Downloads.tsx b/src/components/pages/Profile/History/Downloads.tsx index 0d206b631..ef06b9f01 100644 --- a/src/components/pages/Profile/History/Downloads.tsx +++ b/src/components/pages/Profile/History/Downloads.tsx @@ -1,64 +1,33 @@ -import React, { ReactElement, useEffect, useState } from 'react' +import React, { ReactElement } from 'react' import Table from '../../../atoms/Table' -import { gql } from 'urql' import Time from '../../../atoms/Time' -import web3 from 'web3' import AssetTitle from '../../../molecules/AssetListTitle' -import axios from 'axios' -import { retrieveDDO } from '../../../../utils/aquarius' -import { Logger } from '@oceanprotocol/lib' -import { useSiteMetadata } from '../../../../hooks/useSiteMetadata' -import { useUserPreferences } from '../../../../providers/UserPreferences' -import { fetchDataForMultipleChains } from '../../../../utils/subgraph' -import { OrdersData_tokenOrders as OrdersData } from '../../../../@types/apollo/OrdersData' import NetworkName from '../../../atoms/NetworkName' - -const getTokenOrders = gql` - query OrdersData($user: String!) { - tokenOrders( - orderBy: timestamp - orderDirection: desc - where: { consumer: $user } - ) { - datatokenId { - address - symbol - } - timestamp - tx - } - } -` - -interface DownloadedAssets { - did: string - dtSymbol: string - timestamp: number - networkId: number -} +import { useProfile } from '../../../../providers/Profile' +import { DownloadedAsset } from '../../../../utils/aquarius' const columns = [ { name: 'Data Set', - selector: function getAssetRow(row: DownloadedAssets) { - return + selector: function getAssetRow(row: DownloadedAsset) { + return } }, { name: 'Network', - selector: function getNetwork(row: DownloadedAssets) { + selector: function getNetwork(row: DownloadedAsset) { return } }, { name: 'Datatoken', - selector: function getTitleRow(row: DownloadedAssets) { + selector: function getTitleRow(row: DownloadedAsset) { return row.dtSymbol } }, { name: 'Time', - selector: function getTimeRow(row: DownloadedAssets) { + selector: function getTimeRow(row: DownloadedAsset) { return
) : (
Please connect your Web3 wallet.
diff --git a/src/components/pages/Profile/History/PoolShares.tsx b/src/components/pages/Profile/History/PoolShares.tsx index 48d6e2945..80c1a1a98 100644 --- a/src/components/pages/Profile/History/PoolShares.tsx +++ b/src/components/pages/Profile/History/PoolShares.tsx @@ -3,61 +3,31 @@ import Table from '../../../atoms/Table' import Conversion from '../../../atoms/Price/Conversion' import styles from './PoolShares.module.css' import AssetTitle from '../../../molecules/AssetListTitle' -import { gql } from 'urql' import { PoolShares_poolShares as PoolShare, PoolShares_poolShares_poolId_tokens as PoolSharePoolIdTokens } from '../../../../@types/apollo/PoolShares' import web3 from 'web3' import Token from '../../../organisms/AssetActions/Pool/Token' -import { useUserPreferences } from '../../../../providers/UserPreferences' -import { - fetchDataForMultipleChains, - calculateUserLiquidity -} from '../../../../utils/subgraph' +import { calculateUserLiquidity } from '../../../../utils/subgraph' import NetworkName from '../../../atoms/NetworkName' -import axios from 'axios' +import axios, { CancelToken } from 'axios' import { retrieveDDO } from '../../../../utils/aquarius' import { isValidNumber } from '../../../../utils/numberValidations' import Decimal from 'decimal.js' +import { useProfile } from '../../../../providers/Profile' +import { DDO } from '@oceanprotocol/lib' Decimal.set({ toExpNeg: -18, precision: 18, rounding: 1 }) const REFETCH_INTERVAL = 20000 -const poolSharesQuery = gql` - query PoolShares($user: String) { - poolShares(where: { userAddress: $user, balance_gt: 0.001 }, first: 1000) { - id - balance - userAddress { - id - } - poolId { - id - datatokenAddress - valueLocked - tokens { - id - isDatatoken - symbol - } - oceanReserve - datatokenReserve - totalShares - consumePrice - spotPrice - createTime - } - } - } -` - interface Asset { userLiquidity: number poolShare: PoolShare networkId: number createTime: number + ddo: DDO } function findTokenByType(tokens: PoolSharePoolIdTokens[], type: string) { @@ -126,10 +96,7 @@ const columns = [ { name: 'Data Set', selector: function getAssetRow(row: Asset) { - const did = web3.utils - .toChecksumAddress(row.poolShare.poolId.datatokenAddress) - .replace('0x', 'did:op:') - return + return }, grow: 2 }, @@ -161,38 +128,27 @@ const columns = [ } ] -async function getPoolSharesData(accountId: string, chainIds: number[]) { - const variables = { user: accountId?.toLowerCase() } - const data: PoolShare[] = [] - const result = await fetchDataForMultipleChains( - poolSharesQuery, - variables, - chainIds - ) - for (let i = 0; i < result.length; i++) { - result[i].poolShares.forEach((poolShare: PoolShare) => { - data.push(poolShare) - }) - } - return data -} - -async function getPoolSharesAssets(data: PoolShare[]) { +async function getPoolSharesAssets( + data: PoolShare[], + cancelToken: CancelToken +) { const assetList: Asset[] = [] - const source = axios.CancelToken.source() for (let i = 0; i < data.length; i++) { const did = web3.utils .toChecksumAddress(data[i].poolId.datatokenAddress) .replace('0x', 'did:op:') - const ddo = await retrieveDDO(did, source.token) + const ddo = await retrieveDDO(did, cancelToken) const userLiquidity = calculateUserLiquidity(data[i]) - assetList.push({ - poolShare: data[i], - userLiquidity: userLiquidity, - networkId: ddo?.chainId, - createTime: data[i].poolId.createTime - }) + + ddo && + assetList.push({ + poolShare: data[i], + userLiquidity: userLiquidity, + networkId: ddo?.chainId, + createTime: data[i].poolId.createTime, + ddo + }) } const assets = assetList.sort((a, b) => b.createTime - a.createTime) return assets @@ -203,33 +159,36 @@ export default function PoolShares({ }: { accountId: string }): ReactElement { + const { poolShares, isPoolSharesLoading } = useProfile() const [assets, setAssets] = useState() const [loading, setLoading] = useState(false) const [dataFetchInterval, setDataFetchInterval] = useState() - const { chainIds } = useUserPreferences() - const fetchPoolSharesData = useCallback(async () => { - try { - const data = await getPoolSharesData(accountId, chainIds) - const newAssets = await getPoolSharesAssets(data) + const fetchPoolSharesAssets = useCallback( + async (cancelToken: CancelToken) => { + if (!poolShares || isPoolSharesLoading) return - if (JSON.stringify(assets) !== JSON.stringify(newAssets)) { - setAssets(newAssets) + try { + const assets = await getPoolSharesAssets(poolShares, cancelToken) + setAssets(assets) + } catch (error) { + console.error('Error fetching pool shares: ', error.message) } - } catch (error) { - console.error('Error fetching pool shares: ', error.message) - } - }, [accountId, assets, chainIds]) + }, + [poolShares, isPoolSharesLoading] + ) useEffect(() => { + const cancelTokenSource = axios.CancelToken.source() + async function init() { setLoading(true) - await fetchPoolSharesData() + await fetchPoolSharesAssets(cancelTokenSource.token) setLoading(false) if (dataFetchInterval) return const interval = setInterval(async () => { - await fetchPoolSharesData() + await fetchPoolSharesAssets(cancelTokenSource.token) }, REFETCH_INTERVAL) setDataFetchInterval(interval) } @@ -237,8 +196,9 @@ export default function PoolShares({ return () => { clearInterval(dataFetchInterval) + cancelTokenSource.cancel() } - }, [dataFetchInterval, fetchPoolSharesData]) + }, [dataFetchInterval, fetchPoolSharesAssets]) return accountId ? (
() const [isLoading, setIsLoading] = useState(false) const [page, setPage] = useState(1) - const [service, setServiceType] = useState() + const [service, setServiceType] = useState('dataset OR algorithm') useEffect(() => { - async function getPublished() { - const serviceFiter = - service === undefined ? 'dataset OR algorithm' : `${service}` - if (!accountId) return - const queryPublishedAssets = { - page: page, - offset: 9, - query: { - query_string: { - query: `(publicKey.owner:${accountId}) AND (service.attributes.main.type:${serviceFiter}) AND (${transformChainIdsListToQuery( - chainIds - )})` - } - }, - sort: { created: -1 } - } - try { - const source = axios.CancelToken.source() + if (!accountId) return + const cancelTokenSource = axios.CancelToken.source() + + async function getPublished() { + try { setIsLoading(true) - const result = await queryMetadata(queryPublishedAssets, source.token) + const result = await getPublishedAssets( + accountId, + chainIds, + cancelTokenSource.token, + page, + service + ) setQueryResult(result) } catch (error) { Logger.error(error.message) @@ -55,6 +45,10 @@ export default function PublishedList({ } } getPublished() + + return () => { + cancelTokenSource.cancel() + } }, [accountId, page, appConfig.metadataCacheUri, chainIds, service]) return accountId ? ( @@ -75,6 +69,7 @@ export default function PublishedList({ setPage(newPage) }} className={styles.assets} + noPublisher /> ) : ( diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 451a639ea..2c2012f03 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -4,6 +4,7 @@ import { graphql, PageProps } from 'gatsby' import ProfilePage from '../../components/pages/Profile' import { accountTruncate } from '../../utils/web3' import { useWeb3 } from '../../providers/Web3' +import ProfileProvider from '../../providers/Profile' export default function PageGatsbyProfile(props: PageProps): ReactElement { const { accountId } = useWeb3() @@ -18,7 +19,9 @@ export default function PageGatsbyProfile(props: PageProps): ReactElement { return ( - + + + ) } diff --git a/src/providers/Profile.tsx b/src/providers/Profile.tsx new file mode 100644 index 000000000..cbe8d3bd2 --- /dev/null +++ b/src/providers/Profile.tsx @@ -0,0 +1,291 @@ +import React, { + useContext, + useState, + useEffect, + createContext, + ReactElement, + useCallback, + ReactNode +} from 'react' +import { getPoolSharesData, getUserTokenOrders } from '../utils/subgraph' +import { useUserPreferences } from './UserPreferences' +import { PoolShares_poolShares as PoolShare } from '../@types/apollo/PoolShares' +import { DDO, Logger } from '@oceanprotocol/lib' +import { + DownloadedAsset, + getDownloadAssets, + getPublishedAssets +} from '../utils/aquarius' +import { useSiteMetadata } from '../hooks/useSiteMetadata' +import { Profile } from '../models/Profile' +import { accountTruncate } from '../utils/web3' +import axios, { CancelToken } from 'axios' +import ethereumAddress from 'ethereum-address' +import get3BoxProfile from '../utils/profile' +import web3 from 'web3' + +interface ProfileProviderValue { + profile: Profile + poolShares: PoolShare[] + isPoolSharesLoading: boolean + assets: DDO[] + assetsTotal: number + isEthAddress: boolean + downloads: DownloadedAsset[] + downloadsTotal: number + isDownloadsLoading: boolean +} + +const ProfileContext = createContext({} as ProfileProviderValue) + +const refreshInterval = 10000 // 10 sec. + +function ProfileProvider({ + accountId, + children +}: { + accountId: string + children: ReactNode +}): ReactElement { + const { chainIds } = useUserPreferences() + const { appConfig } = useSiteMetadata() + + const [isEthAddress, setIsEthAddress] = useState() + + // + // Do nothing in all following effects + // when accountId is no ETH address + // + useEffect(() => { + const isEthAddress = ethereumAddress.isAddress(accountId) + setIsEthAddress(isEthAddress) + }, [accountId]) + + // + // 3Box + // + const [profile, setProfile] = useState({ + name: accountTruncate(accountId), + image: null, + description: null, + links: null + }) + + useEffect(() => { + const clearedProfile: Profile = { + name: null, + image: null, + description: null, + links: null + } + + if (!accountId || !isEthAddress) { + setProfile(clearedProfile) + return + } + + const cancelTokenSource = axios.CancelToken.source() + + async function getInfoFrom3Box() { + const profile3Box = await get3BoxProfile( + accountId, + cancelTokenSource.token + ) + if (profile3Box) { + const { name, emoji, description, image, links } = profile3Box + const newName = `${emoji || ''} ${name || accountTruncate(accountId)}` + const newProfile = { + name: newName, + image, + description, + links + } + setProfile(newProfile) + Logger.log('[profile] Found and set 3box profile.', newProfile) + } else { + setProfile(clearedProfile) + Logger.log('[profile] No 3box profile found.') + } + } + getInfoFrom3Box() + + return () => { + cancelTokenSource.cancel() + } + }, [accountId, isEthAddress]) + + // + // POOL SHARES + // + const [poolShares, setPoolShares] = useState() + const [isPoolSharesLoading, setIsPoolSharesLoading] = useState(false) + const [poolSharesInterval, setPoolSharesInterval] = useState() + + const fetchPoolShares = useCallback(async () => { + if (!accountId || !chainIds || !isEthAddress) return + + try { + setIsPoolSharesLoading(true) + const poolShares = await getPoolSharesData(accountId, chainIds) + setPoolShares(poolShares) + Logger.log( + `[profile] Fetched ${poolShares.length} pool shares.`, + poolShares + ) + } catch (error) { + Logger.error('Error fetching pool shares: ', error.message) + } finally { + setIsPoolSharesLoading(false) + } + }, [accountId, chainIds, isEthAddress]) + + useEffect(() => { + async function init() { + await fetchPoolShares() + + if (poolSharesInterval) return + const interval = setInterval(async () => { + await fetchPoolShares() + }, refreshInterval) + setPoolSharesInterval(interval) + } + init() + + return () => { + clearInterval(poolSharesInterval) + } + }, [poolSharesInterval, fetchPoolShares]) + + // + // PUBLISHED ASSETS + // + const [assets, setAssets] = useState() + const [assetsTotal, setAssetsTotal] = useState(0) + // const [assetsWithPrices, setAssetsWithPrices] = useState() + + useEffect(() => { + if (!accountId || !isEthAddress) return + + const cancelTokenSource = axios.CancelToken.source() + + async function getAllPublished() { + try { + const result = await getPublishedAssets( + accountId, + chainIds, + cancelTokenSource.token + ) + setAssets(result.results) + setAssetsTotal(result.totalResults) + Logger.log( + `[profile] Fetched ${result.totalResults} assets.`, + result.results + ) + + // Hint: this would only make sense if we "search" in all subcomponents + // against this provider's state, meaning filtering via js rather then sending + // more queries to Aquarius. + // const assetsWithPrices = await getAssetsBestPrices(result.results) + // setAssetsWithPrices(assetsWithPrices) + } catch (error) { + Logger.error(error.message) + } + } + getAllPublished() + + return () => { + cancelTokenSource.cancel() + } + }, [accountId, appConfig.metadataCacheUri, chainIds, isEthAddress]) + + // + // DOWNLOADS + // + const [downloads, setDownloads] = useState() + const [downloadsTotal, setDownloadsTotal] = useState(0) + const [isDownloadsLoading, setIsDownloadsLoading] = useState() + const [downloadsInterval, setDownloadsInterval] = useState() + + const fetchDownloads = useCallback( + async (cancelToken: CancelToken) => { + if (!accountId || !chainIds) return + + const didList: string[] = [] + const tokenOrders = await getUserTokenOrders(accountId, chainIds) + + for (let i = 0; i < tokenOrders?.length; i++) { + const did = web3.utils + .toChecksumAddress(tokenOrders[i].datatokenId.address) + .replace('0x', 'did:op:') + didList.push(did) + } + + const downloads = await getDownloadAssets( + didList, + tokenOrders, + chainIds, + cancelToken + ) + setDownloads(downloads) + setDownloadsTotal(downloads.length) + Logger.log( + `[profile] Fetched ${downloads.length} download orders.`, + downloads + ) + }, + [accountId, chainIds] + ) + + useEffect(() => { + const cancelTokenSource = axios.CancelToken.source() + + async function getDownloadAssets() { + if (!appConfig?.metadataCacheUri) return + + try { + setIsDownloadsLoading(true) + await fetchDownloads(cancelTokenSource.token) + } catch (err) { + Logger.log(err.message) + } finally { + setIsDownloadsLoading(false) + } + } + getDownloadAssets() + + if (downloadsInterval) return + const interval = setInterval(async () => { + await fetchDownloads(cancelTokenSource.token) + }, refreshInterval) + setDownloadsInterval(interval) + + return () => { + cancelTokenSource.cancel() + clearInterval(downloadsInterval) + } + }, [fetchDownloads, appConfig.metadataCacheUri, downloadsInterval]) + + return ( + + {children} + + ) +} + +// Helper hook to access the provider values +const useProfile = (): ProfileProviderValue => useContext(ProfileContext) + +export { ProfileProvider, useProfile, ProfileProviderValue, ProfileContext } +export default ProfileProvider diff --git a/src/utils/aquarius.ts b/src/utils/aquarius.ts index 10adee35c..54cd909ba 100644 --- a/src/utils/aquarius.ts +++ b/src/utils/aquarius.ts @@ -12,7 +12,16 @@ import { import { AssetSelectionAsset } from '../components/molecules/FormFields/AssetSelection' import { PriceList, getAssetsPriceList } from './subgraph' import axios, { CancelToken, AxiosResponse } from 'axios' +import { OrdersData_tokenOrders as OrdersData } from '../@types/apollo/OrdersData' import { metadataCacheUri } from '../../app.config' +import web3 from '../../tests/unit/__mocks__/web3' + +export interface DownloadedAsset { + dtSymbol: string + timestamp: number + networkId: number + ddo: DDO +} function getQueryForAlgorithmDatasets(algorithmDid: string, chainId?: number) { return { @@ -66,7 +75,8 @@ export async function queryMetadata( try { const response: AxiosResponse = await axios.post( `${metadataCacheUri}/api/v1/aquarius/assets/ddo/query`, - { ...query, cancelToken } + { ...query }, + { cancelToken } ) if (!response || response.status !== 200 || !response.data) return return transformQueryResult(response.data) @@ -108,10 +118,8 @@ export async function getAssetsNames( try { const response: AxiosResponse> = await axios.post( `${metadataCacheUri}/api/v1/aquarius/assets/names`, - { - didList, - cancelToken - } + { didList }, + { cancelToken } ) if (!response || response.status !== 200 || !response.data) return return response.data @@ -204,3 +212,122 @@ export async function getAlgorithmDatasetsForCompute( ) return datasets } + +export async function getPublishedAssets( + accountId: string, + chainIds: number[], + cancelToken: CancelToken, + page?: number, + type?: string +): Promise { + if (!accountId) return + + page = page || 1 + type = type || 'dataset OR algorithm' + + const queryPublishedAssets = { + page, + offset: 9, + query: { + query_string: { + query: `(publicKey.owner:${accountId}) AND (service.attributes.main.type:${type}) AND (${transformChainIdsListToQuery( + chainIds + )})` + } + }, + sort: { created: -1 } + } + + try { + const result = await queryMetadata(queryPublishedAssets, cancelToken) + return result + } catch (error) { + if (axios.isCancel(error)) { + Logger.log(error.message) + } else { + Logger.error(error.message) + } + } +} + +export async function getAssetsFromDidList( + didList: string[], + chainIds: number[], + cancelToken: CancelToken +): Promise { + try { + // TODO: figure out cleaner way to transform string[] into csv + const searchDids = JSON.stringify(didList) + .replace(/,/g, ' ') + .replace(/"/g, '') + .replace(/(\[|\])/g, '') + // for whatever reason ddo.id is not searchable, so use ddo.dataToken instead + .replace(/(did:op:)/g, '0x') + + // safeguard against passed empty didList, preventing 500 from Aquarius + if (!searchDids) return + + const query = { + page: 1, + offset: 1000, + query: { + query_string: { + query: `(${searchDids}) AND (${transformChainIdsListToQuery( + chainIds + )})`, + fields: ['dataToken'], + default_operator: 'OR' + } + }, + sort: { created: -1 } + } + + const queryResult = await queryMetadata(query, cancelToken) + return queryResult + } catch (error) { + Logger.error(error.message) + } +} + +export async function getDownloadAssets( + didList: string[], + tokenOrders: OrdersData[], + chainIds: number[], + cancelToken: CancelToken +): Promise { + const downloadedAssets: DownloadedAsset[] = [] + + try { + const queryResult = await getAssetsFromDidList( + didList, + chainIds, + cancelToken + ) + const ddoList = queryResult?.results + + for (let i = 0; i < tokenOrders?.length; i++) { + const ddo = ddoList.filter( + (ddo) => + tokenOrders[i].datatokenId.address.toLowerCase() === + ddo.dataToken.toLowerCase() + )[0] + + // make sure we are only pushing download orders + if (ddo.service[1].type !== 'access') continue + + downloadedAssets.push({ + ddo, + networkId: ddo.chainId, + dtSymbol: tokenOrders[i].datatokenId.symbol, + timestamp: tokenOrders[i].timestamp + }) + } + + const sortedOrders = downloadedAssets.sort( + (a, b) => b.timestamp - a.timestamp + ) + return sortedOrders + } catch (error) { + Logger.error(error.message) + } +} diff --git a/src/utils/subgraph.ts b/src/utils/subgraph.ts index 182245347..ed253ed81 100644 --- a/src/utils/subgraph.ts +++ b/src/utils/subgraph.ts @@ -1,5 +1,5 @@ import { gql, OperationResult, TypedDocumentNode, OperationContext } from 'urql' -import { DDO } from '@oceanprotocol/lib' +import { DDO, Logger } from '@oceanprotocol/lib' import { getUrqlClientInstance } from '../providers/UrqlProvider' import { getOceanConfig } from './ocean' import web3 from 'web3' @@ -25,8 +25,9 @@ import { PoolShares_poolShares as PoolShare } from '../@types/apollo/PoolShares' import { BestPrice } from '../models/BestPrice' +import { OrdersData_tokenOrders as OrdersData } from '../@types/apollo/OrdersData' -export interface UserTVL { +export interface UserLiquidity { price: string oceanBalance: string } @@ -206,6 +207,52 @@ const UserSharesQuery = gql` } } ` + +const userPoolSharesQuery = gql` + query PoolShares($user: String) { + poolShares(where: { userAddress: $user, balance_gt: 0.001 }, first: 1000) { + id + balance + userAddress { + id + } + poolId { + id + datatokenAddress + valueLocked + tokens { + id + isDatatoken + symbol + } + oceanReserve + datatokenReserve + totalShares + consumePrice + spotPrice + createTime + } + } + } +` + +const UserTokenOrders = gql` + query OrdersData($user: String!) { + tokenOrders( + orderBy: timestamp + orderDirection: desc + where: { consumer: $user } + ) { + datatokenId { + address + symbol + } + timestamp + tx + } + } +` + export function getSubgraphUri(chainId: number): string { const config = getOceanConfig(chainId) return config.subgraphUri @@ -604,7 +651,7 @@ export async function getAccountLiquidityInOwnAssets( accountId: string, chainIds: number[], pools: string[] -): Promise { +): Promise { const queryVariables = { user: accountId.toLowerCase(), pools: pools @@ -630,3 +677,48 @@ export async function getAccountLiquidityInOwnAssets( oceanBalance: totalOceanLiquidity.toString() } } + +export async function getPoolSharesData( + accountId: string, + chainIds: number[] +): Promise { + const variables = { user: accountId?.toLowerCase() } + const data: PoolShare[] = [] + const result = await fetchDataForMultipleChains( + userPoolSharesQuery, + variables, + chainIds + ) + for (let i = 0; i < result.length; i++) { + result[i].poolShares.forEach((poolShare: PoolShare) => { + data.push(poolShare) + }) + } + return data +} + +export async function getUserTokenOrders( + accountId: string, + chainIds: number[] +): Promise { + const data: OrdersData[] = [] + const variables = { user: accountId?.toLowerCase() } + + try { + const tokenOrders = await fetchDataForMultipleChains( + UserTokenOrders, + variables, + chainIds + ) + + for (let i = 0; i < tokenOrders?.length; i++) { + tokenOrders[i].tokenOrders.forEach((tokenOrder: OrdersData) => { + data.push(tokenOrder) + }) + } + + return data + } catch (error) { + Logger.error(error.message) + } +}