Commit 18939aa8 authored by Charles Bachmeier's avatar Charles Bachmeier Committed by GitHub

feat: Migrate NFT Activity to GraphQL (#6103)

* add new activity query

* add nftactivity hook and fix query

* converted activity type to accept optionals, hook should return activity type

* single asset activity

* relaystylepagination

* working on local endpoint

* working timestamps

* updated endpoint

* use correct env var

* undo asset testing

* forgot last

* undo more testing

* don't break on null address

* working loading state

* better loading states

* handle marketplace type update

* remove types

* properly format price and reduce redundancy

* handle null price

---------
Co-authored-by: default avatarCharles Bachmeier <charlie@genie.xyz>
parent bc251230
......@@ -19,6 +19,7 @@ export const apolloClient = new ApolloClient({
fields: {
nftBalances: relayStylePagination(),
nftAssets: relayStylePagination(),
nftActivity: relayStylePagination(),
// tell apollo client how to reference Token items in the cache after being fetched by queries that return Token[]
token: {
read(_, { args, toReference }): Reference | undefined {
......
import { WatchQueryFetchPolicy } from '@apollo/client'
import { useNftGraphqlEnabled } from 'featureFlags/flags/nftlGraphql'
import gql from 'graphql-tag'
import { ActivityEvent } from 'nft/types'
import { useCallback, useMemo } from 'react'
import { NftActivityFilterInput, useNftActivityQuery } from '../__generated__/types-and-hooks'
gql`
query NftActivity($filter: NftActivityFilterInput, $after: String, $first: Int) {
nftActivity(filter: $filter, after: $after, first: $first) {
edges {
node {
id
address
tokenId
asset {
id
metadataUrl
image {
id
url
}
smallImage {
id
url
}
name
rarities {
id
provider
rank
score
}
suspiciousFlag
nftContract {
id
standard
}
collection {
id
image {
id
url
}
}
}
type
marketplace
fromAddress
toAddress
transactionHash
price {
id
value
}
orderStatus
quantity
url
timestamp
}
}
pageInfo {
endCursor
hasNextPage
hasPreviousPage
startCursor
}
}
}
`
export function useNftActivity(filter: NftActivityFilterInput, first?: number, fetchPolicy?: WatchQueryFetchPolicy) {
const isNftGraphqlEnabled = useNftGraphqlEnabled()
const { data, loading, fetchMore, error } = useNftActivityQuery({
variables: {
filter,
first,
},
skip: !isNftGraphqlEnabled,
fetchPolicy,
})
const hasNext = data?.nftActivity?.pageInfo?.hasNextPage
const loadMore = useCallback(
() =>
fetchMore({
variables: {
after: data?.nftActivity?.pageInfo?.endCursor,
},
}),
[data, fetchMore]
)
const nftActivity: ActivityEvent[] | undefined = useMemo(
() =>
data?.nftActivity?.edges?.map((queryActivity) => {
const activity = queryActivity?.node
const asset = activity?.asset
return {
collectionAddress: activity.address,
tokenId: activity.tokenId,
tokenMetadata: {
name: asset?.name,
imageUrl: asset?.image?.url,
smallImageUrl: asset?.smallImage?.url,
metadataUrl: asset?.metadataUrl,
rarity: {
primaryProvider: 'Rarity Sniper', // TODO update when backend adds more providers
providers: asset?.rarities?.map((rarity) => {
return {
...rarity,
provider: 'Rarity Sniper',
}
}),
},
suspiciousFlag: asset?.suspiciousFlag,
standard: asset?.nftContract?.standard,
},
eventType: activity.type,
marketplace: activity.marketplace,
fromAddress: activity.fromAddress,
toAddress: activity.toAddress,
transactionHash: activity.transactionHash,
orderStatus: activity.orderStatus,
price: activity.price?.value.toString(),
symbol: asset?.collection?.image?.url,
quantity: activity.quantity,
url: activity.url,
eventTimestamp: activity.timestamp * 1000,
}
}),
[data]
)
return useMemo(
() => ({ nftActivity, hasNext, loadMore, loading, error }),
[hasNext, loadMore, loading, nftActivity, error]
)
}
import { OpacityHoverState } from 'components/Common'
import { useNftGraphqlEnabled } from 'featureFlags/flags/nftlGraphql'
import { NftActivityType } from 'graphql/data/__generated__/types-and-hooks'
import { useNftActivity } from 'graphql/data/nft/NftActivity'
import { Box } from 'nft/components/Box'
import { Column, Row } from 'nft/components/Flex'
import { themeVars, vars } from 'nft/css/sprinkles.css'
......@@ -6,7 +9,7 @@ import { useBag, useIsMobile } from 'nft/hooks'
import { ActivityFetcher } from 'nft/queries/genie/ActivityFetcher'
import { ActivityEvent, ActivityEventResponse, ActivityEventType } from 'nft/types'
import { fetchPrice } from 'nft/utils/fetchPrice'
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'
import { useCallback, useEffect, useReducer, useState } from 'react'
import InfiniteScroll from 'react-infinite-scroll-component'
import { useInfiniteQuery } from 'react-query'
import { useIsDarkMode } from 'state/user/hooks'
......@@ -63,6 +66,7 @@ export const reduceFilters = (state: typeof initialFilterState, action: { eventT
const baseHref = (event: ActivityEvent) => `/#/nfts/asset/${event.collectionAddress}/${event.tokenId}?origin=activity`
export const Activity = ({ contractAddress, rarityVerified, collectionName, chainId }: ActivityProps) => {
const isNftGraphqlEnabled = useNftGraphqlEnabled()
const [activeFilters, filtersDispatch] = useReducer(reduceFilters, initialFilterState)
const {
......@@ -102,11 +106,33 @@ export const Activity = ({ contractAddress, rarityVerified, collectionName, chai
}
)
const events = useMemo(
() => (isSuccess ? eventsData?.pages.map((page) => page.events).flat() : null),
[isSuccess, eventsData]
const {
nftActivity: gqlEventsData,
hasNext,
loadMore,
loading,
} = useNftActivity(
{
activityTypes: Object.keys(activeFilters)
.map((key) => key as NftActivityType)
.filter((key) => activeFilters[key]),
address: contractAddress,
},
25
)
const { events, gatedHasNext, gatedLoadMore, gatedLoading, gatedIsLoadingMore } = {
events: isNftGraphqlEnabled
? gqlEventsData
: isSuccess
? eventsData?.pages.map((page) => page.events).flat()
: undefined,
gatedHasNext: isNftGraphqlEnabled ? hasNext : hasNextPage,
gatedLoadMore: isNftGraphqlEnabled ? loadMore : fetchNextPage,
gatedLoading: isNftGraphqlEnabled ? loading : isLoading,
gatedIsLoadingMore: isNftGraphqlEnabled ? hasNext && gqlEventsData?.length : isFetchingNextPage,
}
const itemsInBag = useBag((state) => state.itemsInBag)
const addAssetsToBag = useBag((state) => state.addAssetsToBag)
const removeAssetsFromBag = useBag((state) => state.removeAssetsFromBag)
......@@ -147,51 +173,63 @@ export const Activity = ({ contractAddress, rarityVerified, collectionName, chai
<Filter eventType={ActivityEventType.Sale} />
<Filter eventType={ActivityEventType.Transfer} />
</Row>
{isLoading && <ActivityLoader />}
{events && (
<Column marginTop="36">
<HeaderRow />
<InfiniteScroll
next={fetchNextPage}
hasMore={!!hasNextPage}
loader={isFetchingNextPage ? <ActivityPageLoader rowCount={2} /> : null}
dataLength={events?.length ?? 0}
style={{ overflow: 'unset' }}
>
{events.map((event, i) => (
<Box as="a" data-testid="nft-activity-row" href={baseHref(event)} className={styles.eventRow} key={i}>
<ItemCell
event={event}
rarityVerified={rarityVerified}
collectionName={collectionName}
eventTimestamp={event.eventTimestamp}
isMobile={isMobile}
/>
<EventCell
eventType={event.eventType}
eventTimestamp={event.eventTimestamp}
eventTransactionHash={event.transactionHash}
price={event.price}
isMobile={isMobile}
/>
<PriceCell marketplace={event.marketplace} price={event.price} />
<AddressCell address={event.fromAddress} chainId={chainId} />
<AddressCell address={event.toAddress} chainId={chainId} desktopLBreakpoint />
<BuyCell
event={event}
collectionName={collectionName}
selectAsset={addAssetsToBag}
removeAsset={removeAssetsFromBag}
itemsInBag={itemsInBag}
cartExpanded={cartExpanded}
toggleCart={toggleCart}
isMobile={isMobile}
ethPriceInUSD={ethPriceInUSD}
/>
</Box>
))}
</InfiniteScroll>
</Column>
{gatedLoading ? (
<ActivityLoader />
) : (
events && (
<Column marginTop="36">
<HeaderRow />
<InfiniteScroll
next={gatedLoadMore}
hasMore={!!gatedHasNext}
loader={gatedIsLoadingMore ? <ActivityPageLoader rowCount={2} /> : null}
dataLength={events?.length ?? 0}
style={{ overflow: 'unset' }}
>
{events.map(
(event, i) =>
event.eventType && (
<Box
as="a"
data-testid="nft-activity-row"
href={baseHref(event)}
className={styles.eventRow}
key={i}
>
<ItemCell
event={event}
rarityVerified={rarityVerified}
collectionName={collectionName}
eventTimestamp={event.eventTimestamp}
isMobile={isMobile}
/>
<EventCell
eventType={event.eventType}
eventTimestamp={event.eventTimestamp}
eventTransactionHash={event.transactionHash}
price={event.price}
isMobile={isMobile}
/>
<PriceCell marketplace={event.marketplace} price={event.price} />
<AddressCell address={event.fromAddress} chainId={chainId} />
<AddressCell address={event.toAddress} chainId={chainId} desktopLBreakpoint />
<BuyCell
event={event}
collectionName={collectionName}
selectAsset={addAssetsToBag}
removeAsset={removeAssetsFromBag}
itemsInBag={itemsInBag}
cartExpanded={cartExpanded}
toggleCart={toggleCart}
isMobile={isMobile}
ethPriceInUSD={ethPriceInUSD}
/>
</Box>
)
)}
</InfiniteScroll>
</Column>
)
)}
</Box>
)
......
import { Trans } from '@lingui/macro'
import { OpacityHoverState, ScrollBarStyles } from 'components/Common'
import { LoadingBubble } from 'components/Tokens/loading'
import { useNftGraphqlEnabled } from 'featureFlags/flags/nftlGraphql'
import { EventCell, MarketplaceIcon } from 'nft/components/collection/ActivityCells'
import { ActivityEventResponse } from 'nft/types'
import { ActivityEvent } from 'nft/types'
import { shortenAddress } from 'nft/utils/address'
import { formatEthPrice } from 'nft/utils/currency'
import { formatEth, formatEthPrice } from 'nft/utils/currency'
import { getTimeDifference } from 'nft/utils/date'
import { putCommas } from 'nft/utils/putCommas'
import { ReactNode } from 'react'
......@@ -147,14 +148,19 @@ export const LoadingAssetActivity = ({ rowCount }: { rowCount: number }) => {
)
}
const AssetActivity = ({ eventsData }: { eventsData: ActivityEventResponse | undefined }) => {
const AssetActivity = ({ events }: { events: ActivityEvent[] | undefined }) => {
const isNftGraphqlEnabled = useNftGraphqlEnabled()
return (
<ActivityTable>
{eventsData?.events &&
eventsData.events.map((event, index) => {
{events &&
events.map((event, index) => {
const { eventTimestamp, eventType, fromAddress, marketplace, price, toAddress, transactionHash } = event
const formattedPrice = price ? putCommas(formatEthPrice(price)).toString() : null
const formattedPrice = price
? isNftGraphqlEnabled
? formatEth(parseFloat(price ?? ''))
: putCommas(formatEthPrice(price)).toString()
: null
if (!eventType) return null
return (
<TR key={index}>
<TD>
......@@ -189,7 +195,7 @@ const AssetActivity = ({ eventsData }: { eventsData: ActivityEventResponse | und
</Link>
)}
</TD>
<TD>{eventTimestamp && getTimeDifference(eventTimestamp.toString())}</TD>
<TD>{eventTimestamp && getTimeDifference(eventTimestamp.toString(), isNftGraphqlEnabled)}</TD>
</TR>
)
})}
......
import { OpacityHoverState, ScrollBarStyles } from 'components/Common'
import Resource from 'components/Tokens/TokenDetails/Resource'
import { MouseoverTooltip } from 'components/Tooltip/index'
import { useNftGraphqlEnabled } from 'featureFlags/flags/nftlGraphql'
import { NftActivityType } from 'graphql/data/__generated__/types-and-hooks'
import { useNftActivity } from 'graphql/data/nft/NftActivity'
import { Box } from 'nft/components/Box'
import { reduceFilters } from 'nft/components/collection/Activity'
import { LoadingSparkle } from 'nft/components/common/Loading/LoadingSparkle'
......@@ -10,7 +13,7 @@ import { themeVars, vars } from 'nft/css/sprinkles.css'
import { ActivityFetcher } from 'nft/queries/genie/ActivityFetcher'
import { ActivityEventResponse, ActivityEventType, CollectionInfoForAsset, GenieAsset } from 'nft/types'
import { shortenAddress } from 'nft/utils/address'
import { formatEthPrice } from 'nft/utils/currency'
import { formatEth, formatEthPrice } from 'nft/utils/currency'
import { isAudio } from 'nft/utils/isAudio'
import { isVideo } from 'nft/utils/isVideo'
import { putCommas } from 'nft/utils/putCommas'
......@@ -244,6 +247,7 @@ interface AssetDetailsProps {
}
export const AssetDetails = ({ asset, collection }: AssetDetailsProps) => {
const isNftGraphqlEnabled = useNftGraphqlEnabled()
const [dominantColor] = useState<[number, number, number]>([0, 0, 0])
const { rarityProvider } = useMemo(
......@@ -299,10 +303,26 @@ export const AssetDetails = ({ asset, collection }: AssetDetailsProps) => {
refetchOnMount: false,
}
)
const { nftActivity: gqlPriceData } = useNftActivity(
{
activityTypes: [NftActivityType.Sale],
address: contractAddress,
tokenId: token_id,
},
1,
'no-cache'
)
const lastSalePrice = priceData?.events[0]?.price ?? null
const formattedEthprice = formatEthPrice(lastSalePrice ?? '') || 0
const formattedPrice = lastSalePrice ? putCommas(formattedEthprice).toString() : null
// TODO simplify typecasting when removing graphql flag
const lastSalePrice = isNftGraphqlEnabled ? gqlPriceData?.[0]?.price : priceData?.events[0]?.price
const formattedEthprice = isNftGraphqlEnabled
? formatEth(parseFloat(lastSalePrice ?? ''))
: formatEthPrice(lastSalePrice) || 0
const formattedPrice = isNftGraphqlEnabled
? formattedEthprice
: lastSalePrice
? putCommas(parseFloat(formattedEthprice.toString())).toString()
: null
const [activeFilters, filtersDispatch] = useReducer(reduceFilters, initialFilterState)
const Filter = useCallback(
......@@ -365,13 +385,48 @@ export const AssetDetails = ({ asset, collection }: AssetDetailsProps) => {
}
)
const rarity = asset?.rarity?.providers?.length ? asset?.rarity?.providers?.[0] : undefined
const {
nftActivity: gqlEventsData,
hasNext,
loadMore,
loading,
error,
} = useNftActivity(
{
activityTypes: Object.keys(activeFilters)
.map((key) => key as NftActivityType)
.filter((key) => activeFilters[key]),
address: contractAddress,
tokenId: token_id,
},
25
)
const { events, gatedHasNext, gatedLoadMore, gatedLoading, gatedSuccess } = useMemo(() => {
return {
events: isNftGraphqlEnabled ? gqlEventsData : eventsData?.pages.map((page) => page.events).flat(),
gatedHasNext: isNftGraphqlEnabled ? hasNext : hasNextPage,
gatedLoadMore: isNftGraphqlEnabled ? loadMore : fetchNextPage,
gatedLoading: isNftGraphqlEnabled ? loading : isActivityLoading,
gatedSuccess: isNftGraphqlEnabled ? !error : isSuccess,
}
}, [
error,
eventsData?.pages,
fetchNextPage,
gqlEventsData,
hasNext,
hasNextPage,
isActivityLoading,
isNftGraphqlEnabled,
isSuccess,
loadMore,
loading,
])
const rarity = asset?.rarity?.providers?.[0]
const [showHolder, setShowHolder] = useState(false)
const rarityProviderLogo = getRarityProviderLogo(rarity?.provider)
const events = useMemo(
() => (isSuccess ? eventsData?.pages.map((page) => page.events).flat() : null),
[isSuccess, eventsData]
)
return (
<Column>
......@@ -433,11 +488,12 @@ export const AssetDetails = ({ asset, collection }: AssetDetailsProps) => {
<Filter eventType={ActivityEventType.Transfer} />
<Filter eventType={ActivityEventType.CancelListing} />
</ActivitySelectContainer>
{isActivityLoading && <LoadingAssetActivity rowCount={10} />}
{events && events.length > 0 ? (
{gatedLoading ? (
<LoadingAssetActivity rowCount={10} />
) : events && events.length > 0 ? (
<InfiniteScroll
next={fetchNextPage}
hasMore={!!hasNextPage}
next={gatedLoadMore}
hasMore={!!gatedHasNext}
loader={
isFetchingNextPage && (
<Center>
......@@ -448,11 +504,11 @@ export const AssetDetails = ({ asset, collection }: AssetDetailsProps) => {
dataLength={events?.length ?? 0}
scrollableTarget="activityContainer"
>
<AssetActivity eventsData={{ events }} />
<AssetActivity events={events} />
</InfiniteScroll>
) : (
<>
{!isActivityLoading && (
{gatedSuccess && events && (
<EmptyActivitiesContainer>
<div>No activities yet</div>
<Link to={`/nfts/collection/${asset.address}`}>View collection items</Link>{' '}
......
import { Markets, TokenType } from '../common'
import { NftActivityType, NftStandard, OrderStatus } from 'graphql/data/__generated__/types-and-hooks'
import { Markets, Rarity, TokenType } from '../common'
export interface AssetPayload {
filters: {
traits?: Record<string, string[]>
......@@ -57,13 +59,6 @@ export enum ActivityEventTypeDisplay {
'CANCEL_LISTING' = 'Cancellation',
}
export enum OrderStatus {
VALID = 'VALID',
EXECUTED = 'EXECUTED',
CANCELLED = 'CANCELLED',
EXPIRED = 'EXPIRED',
}
export interface ActivityFilter {
collectionAddress?: string
eventTypes?: ActivityEventType[]
......@@ -83,31 +78,29 @@ export interface TokenRarity {
}
export interface TokenMetadata {
name: string
imageUrl: string
smallImageUrl: string
metadataUrl: string
rarity: TokenRarity
suspiciousFlag: boolean
suspiciousFlaggedBy: string
standard: TokenType
name?: string
imageUrl?: string
smallImageUrl?: string
metadataUrl?: string
rarity?: TokenRarity | Rarity
suspiciousFlag?: boolean
standard?: TokenType | NftStandard
}
// TODO when deprecating activity query, remove all outdated types (former in optional fields)
export interface ActivityEvent {
collectionAddress: string
collectionAddress?: string
tokenId?: string
tokenMetadata?: TokenMetadata
eventType: ActivityEventType
marketplace?: Markets
fromAddress: string
eventType?: NftActivityType
marketplace?: Markets | string
fromAddress?: string
toAddress?: string
transactionHash?: string
orderHash?: string
orderStatus?: OrderStatus
price?: string
symbol?: string
quantity?: number
auctionType?: string
url?: string
eventTimestamp?: number
}
......@@ -9,7 +9,7 @@ import { isAddress } from '@ethersproject/address'
*/
export function shortenAddress(address: string, charsStart = 4, charsEnd?: number): string {
const parsed = isAddress(address)
if (!parsed) throw Error(`Invalid 'address' parameter '${address}'.`)
if (!parsed) return ''
return `${address.substring(0, charsStart + 2)}...${address.substring(42 - (charsEnd || charsStart))}`
}
import { BigNumber } from '@ethersproject/bignumber'
import { formatEther } from '@ethersproject/units'
import { parseEther } from 'ethers/lib/utils'
import { ActivityEvent, GenieAsset } from 'nft/types'
export const buildActivityAsset = (event: ActivityEvent, collectionName: string, ethPriceInUSD: number): GenieAsset => {
import { formatEth } from './currency'
export const buildActivityAsset = (
event: ActivityEvent,
collectionName: string,
ethPriceInUSD: number,
isNftGraphqlEnabled: boolean
): GenieAsset => {
const assetUsdPrice = event.price
? formatEther(
BigNumber.from(event.price)
.mul(BigNumber.from(Math.trunc(ethPriceInUSD * 100)))
.div(100)
)
? isNftGraphqlEnabled
? formatEth(parseFloat(event.price) * ethPriceInUSD)
: formatEther(
BigNumber.from(event.price)
.mul(BigNumber.from(Math.trunc(ethPriceInUSD * 100)))
.div(100)
)
: '0'
const weiPrice = isNftGraphqlEnabled ? (event.price ? parseEther(event.price) : '') : event.price
return {
address: event.collectionAddress,
collectionName,
......@@ -23,8 +35,8 @@ export const buildActivityAsset = (event: ActivityEvent, collectionName: string,
collectionSymbol: event.symbol,
priceInfo: {
USDPrice: assetUsdPrice,
ETHPrice: event.price,
basePrice: event.price,
ETHPrice: weiPrice,
basePrice: weiPrice,
baseAsset: 'ETH',
},
tokenType: event.tokenMetadata?.standard,
......
......@@ -3,8 +3,8 @@ export const isValidDate = (date: number): boolean => {
return isNaN(d) ? false : true
}
export const getTimeDifference = (eventTimestamp: string) => {
const date = new Date(eventTimestamp).getTime()
export const getTimeDifference = (eventTimestamp: string, isNftGraphqlEnabled: boolean) => {
const date = isNftGraphqlEnabled ? parseFloat(eventTimestamp) : new Date(eventTimestamp).getTime()
const diff = new Date().getTime() - date
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment