mirror of
https://github.com/status-im/status-web.git
synced 2026-08-31 14:21:18 +00:00
add received transaction history (#722)
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@status-im/components": patch
|
||||
"@status-im/wallet": patch
|
||||
"wallet": patch
|
||||
---
|
||||
|
||||
add received transaction history
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
"@status-im/wallet": patch
|
||||
'@status-im/wallet': patch
|
||||
---
|
||||
|
||||
change feedback link
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
"@status-im/wallet": patch
|
||||
"wallet": patch
|
||||
'@status-im/wallet': patch
|
||||
'wallet': patch
|
||||
---
|
||||
|
||||
Fixes onBack in import recovery phrase flow
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
"@status-im/wallet": patch
|
||||
"wallet": patch
|
||||
'@status-im/wallet': patch
|
||||
'wallet': patch
|
||||
---
|
||||
|
||||
add link to feedback, change onboarding copy
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
"wallet": patch
|
||||
'wallet': patch
|
||||
---
|
||||
|
||||
fix failing api calls due to inactive service worker
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
|
||||
import type { NetworkType } from '@status-im/wallet/data'
|
||||
import type { Activity, NetworkType } from '@status-im/wallet/data'
|
||||
|
||||
const PAGE_LIMIT = 20
|
||||
|
||||
type Props = {
|
||||
address: string
|
||||
address: string | undefined
|
||||
}
|
||||
|
||||
const getTransfers = async (
|
||||
@@ -13,7 +13,9 @@ const getTransfers = async (
|
||||
networks: NetworkType[],
|
||||
pageKeys: Partial<Record<NetworkType, string>> = {},
|
||||
) => {
|
||||
const url = new URL('http://localhost:3030/api/trpc/activities.page')
|
||||
const url = new URL(
|
||||
`${import.meta.env.WXT_STATUS_API_URL}/api/trpc/activities.page`,
|
||||
)
|
||||
|
||||
url.searchParams.set(
|
||||
'input',
|
||||
@@ -67,6 +69,9 @@ export const useActivities = ({ address }: Props) => {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ['activities', address, networks],
|
||||
queryFn: async ({ pageParam = {} }) => {
|
||||
if (!address) {
|
||||
return { activities: [], nextPage: {} }
|
||||
}
|
||||
const result = await getTransfers(
|
||||
address,
|
||||
networks as NetworkType[],
|
||||
@@ -77,8 +82,9 @@ export const useActivities = ({ address }: Props) => {
|
||||
nextPage: result.nextPageKeys,
|
||||
}
|
||||
},
|
||||
enabled: !!address,
|
||||
getNextPageParam: (lastPage: {
|
||||
activities: []
|
||||
activities: Activity[]
|
||||
nextPage: Partial<Record<NetworkType, string | undefined>>
|
||||
}) => {
|
||||
const hasMore = Object.values(lastPage.nextPage).some(Boolean)
|
||||
|
||||
@@ -3,28 +3,39 @@ import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import SplittedLayout from '@/components/splitted-layout'
|
||||
import { useActivities } from '@/hooks/use-activities'
|
||||
import { useWallet } from '@/providers/wallet-context'
|
||||
|
||||
export const Route = createFileRoute('/portfolio/activity/')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
|
||||
const { currentWallet, isLoading: isWalletLoading } = useWallet()
|
||||
const address = currentWallet?.activeAccounts[0].address
|
||||
|
||||
const { data, isLoading } = useActivities({ address })
|
||||
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useActivities({ address })
|
||||
const activities = data?.pages.flatMap(page => page.activities) ?? []
|
||||
|
||||
return (
|
||||
<SplittedLayout
|
||||
list={
|
||||
activities ? (
|
||||
<ActivityList activities={activities} />
|
||||
activities.length > 0 && address ? (
|
||||
<ActivityList
|
||||
activities={activities}
|
||||
userAddress={address}
|
||||
onLoadMore={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isLoadingMore={isFetchingNextPage}
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-4 flex flex-col gap-3">Empty state</div>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{!address ? 'No wallet selected' : 'No activity'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
detail={<FeedbackSection />}
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoading || isWalletLoading}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ const ContextTag = (props: Props, ref: React.Ref<HTMLDivElement>) => {
|
||||
<span className={iconStyles({ size, rounded: true, offset: size })}>
|
||||
{cloneElement(icon)}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
<span className="whitespace-nowrap">{label}</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ReceiveIcon, SendIcon } from '@status-im/icons/20'
|
||||
|
||||
type ActivityDirectionProps = {
|
||||
direction: 'sent' | 'received'
|
||||
}
|
||||
|
||||
const ActivityDirection = (props: ActivityDirectionProps) => {
|
||||
const { direction } = props
|
||||
const Icon = direction === 'sent' ? SendIcon : ReceiveIcon
|
||||
const text = direction === 'sent' ? 'Sent' : 'Received'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-15 font-600 text-neutral-100">
|
||||
<Icon className="text-neutral-50" />
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { ActivityDirection }
|
||||
@@ -0,0 +1,124 @@
|
||||
import { ContextTag } from '@status-im/components'
|
||||
import { ExternalIcon } from '@status-im/icons/20'
|
||||
import { cx } from 'class-variance-authority'
|
||||
|
||||
import { CurrencyAmount } from '../../currency-amount'
|
||||
import { NetworkLogo } from '../../network-logo'
|
||||
import { RelativeDate } from '../../relative-date'
|
||||
import { shortenAddress } from '../../shorten-address'
|
||||
import { formatTokenAmount } from '../../token-amount'
|
||||
import { ActivityDirection } from './activity-direction'
|
||||
import { ActivityStatus } from './activity-status'
|
||||
import { ActivityTokenLogo } from './activity-token-logo'
|
||||
|
||||
import type { Activity } from '@status-im/wallet/data'
|
||||
|
||||
type ActivityItemProps = {
|
||||
activity: Activity
|
||||
userAddress: string
|
||||
}
|
||||
|
||||
function getTokenActivityLabel(activity: Activity): string {
|
||||
if (activity.category === 'erc721') {
|
||||
return '1 NFT'
|
||||
}
|
||||
|
||||
if (
|
||||
activity.category === 'erc1155' &&
|
||||
Array.isArray(activity.erc1155Metadata) &&
|
||||
activity.erc1155Metadata.length > 0
|
||||
) {
|
||||
const total = activity.erc1155Metadata.reduce(
|
||||
(sum, meta) => sum + parseInt(meta.value, 16),
|
||||
0,
|
||||
)
|
||||
// ERC1155 can have multiple NFTs or FTs, so we show the total count
|
||||
return `${total} Token${total > 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
return '1 Asset'
|
||||
}
|
||||
|
||||
const ActivityItem = (props: ActivityItemProps) => {
|
||||
const { activity, userAddress } = props
|
||||
|
||||
const outgoingTransaction =
|
||||
activity.from.toLowerCase() === userAddress.toLowerCase()
|
||||
const assetSymbol =
|
||||
activity.asset || (activity.category === 'external' ? 'ETH' : null)
|
||||
const eurValue = Number(activity.eurRate) * Number(activity.value)
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`https://etherscan.io/tx/${activity.hash}`}
|
||||
target="_blank"
|
||||
className="grid grid-cols-[2fr_1fr_1fr] gap-4 p-3 transition-colors focus-within:bg-neutral-5 hover:bg-neutral-5"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<ActivityTokenLogo
|
||||
symbol={assetSymbol || 'ETH'}
|
||||
address={activity.rawContract?.address ?? ''}
|
||||
/>
|
||||
<div className="absolute bottom-[-4px] right-[-4px] size-[18px] rounded-full border border-white-100">
|
||||
<NetworkLogo name={activity.network} size={16} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2 text-15 font-600 sm:max-w-full">
|
||||
<ActivityDirection
|
||||
direction={outgoingTransaction ? 'sent' : 'received'}
|
||||
/>
|
||||
<RelativeDate
|
||||
timestamp={activity.metadata.blockTimestamp}
|
||||
className="text-13 font-400 text-neutral-40"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end text-13 font-400 text-neutral-50">
|
||||
<ContextTag type="label" size="24">
|
||||
{shortenAddress(
|
||||
outgoingTransaction ? activity.to : activity.from,
|
||||
)}
|
||||
</ContextTag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end justify-center">
|
||||
{assetSymbol ? (
|
||||
<div className="flex flex-col items-end gap-1 truncate">
|
||||
<ContextTag type="label" size="24">
|
||||
{`${
|
||||
outgoingTransaction ? '-' : '+'
|
||||
} ${formatTokenAmount(activity.value, 'precise')} ${assetSymbol}`}
|
||||
</ContextTag>
|
||||
{eurValue > 0 && (
|
||||
<div
|
||||
className={cx(
|
||||
'flex items-center gap-1 text-13 font-500',
|
||||
outgoingTransaction ? 'text-danger-50' : 'text-success-50',
|
||||
)}
|
||||
>
|
||||
{outgoingTransaction ? '-' : '+'}
|
||||
<CurrencyAmount
|
||||
value={eurValue}
|
||||
format="standard"
|
||||
className="text-13 font-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ContextTag type="label" size="24">
|
||||
{getTokenActivityLabel(activity)}
|
||||
</ContextTag>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1 text-13 font-400 text-neutral-50">
|
||||
<ActivityStatus status={activity.status} />
|
||||
<ExternalIcon className="text-neutral-50" />
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export { ActivityItem }
|
||||
@@ -0,0 +1,52 @@
|
||||
import { CheckIcon, NegativeStateIcon, PendingIcon } from '@status-im/icons/20'
|
||||
import { cx } from 'class-variance-authority'
|
||||
import { match } from 'ts-pattern'
|
||||
|
||||
import type { Activity } from '@status-im/wallet/data'
|
||||
|
||||
type ActivityStatusProps = {
|
||||
status: Activity['status']
|
||||
}
|
||||
|
||||
const ActivityStatus = (props: ActivityStatusProps) => {
|
||||
const { status } = props
|
||||
|
||||
const baseClass =
|
||||
'flex items-center gap-1 rounded-20 border pl-[5px] pr-[8px] py-[3px] h-6'
|
||||
|
||||
return match(status)
|
||||
.with('success', () => {
|
||||
return (
|
||||
<div className={cx(baseClass, 'border-success-50 bg-success-50/10')}>
|
||||
<CheckIcon className="text-success-50" />
|
||||
<div className="text-13 font-500 text-success-50">Success</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
.with('failed', () => {
|
||||
return (
|
||||
<div className={cx(baseClass, 'border-danger-50/20 bg-danger-50/10')}>
|
||||
<NegativeStateIcon className="text-danger-50" />
|
||||
<div className="text-13 font-500 text-danger-50">Failed</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
.with('pending', () => {
|
||||
return (
|
||||
<div className={cx(baseClass, 'border-neutral-20 bg-neutral-10')}>
|
||||
<PendingIcon className="text-neutral-40" />
|
||||
<div className="flex text-13 font-400 text-neutral-40">Pending</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
.with('unknown', () => {
|
||||
return (
|
||||
<div className={cx(baseClass, 'border-neutral-20 bg-neutral-10')}>
|
||||
<div className="flex text-13 font-400 text-neutral-50">Unknown</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
.exhaustive()
|
||||
}
|
||||
|
||||
export { ActivityStatus }
|
||||
@@ -0,0 +1,45 @@
|
||||
import erc20TokenList from '../../../constants/erc20.json'
|
||||
|
||||
type ActivityTokenLogoProps = {
|
||||
symbol: string
|
||||
address: string
|
||||
}
|
||||
|
||||
const ActivityTokenLogo = (props: ActivityTokenLogoProps) => {
|
||||
const { symbol, address } = props
|
||||
|
||||
const getActivityTokenLogo = (symbol: string, contractAddress?: string) => {
|
||||
if (symbol === 'ETH') {
|
||||
return 'https://assets.coingecko.com/coins/images/279/large/ethereum.png'
|
||||
}
|
||||
|
||||
const token = erc20TokenList.tokens.find(
|
||||
token =>
|
||||
token.symbol === symbol ||
|
||||
(contractAddress &&
|
||||
token.address.toLowerCase() === contractAddress.toLowerCase()),
|
||||
)
|
||||
|
||||
return token?.logoURI ?? ''
|
||||
}
|
||||
|
||||
const src = getActivityTokenLogo(symbol, address)
|
||||
|
||||
if (!src) {
|
||||
return (
|
||||
<div className="flex size-8 flex-shrink-0 items-center justify-center rounded-full bg-neutral-10 text-11 font-600 text-neutral-50">
|
||||
{symbol.slice(0, 4).toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
className="size-8 flex-shrink-0 rounded-full bg-neutral-10"
|
||||
alt={symbol}
|
||||
src={src}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { ActivityTokenLogo }
|
||||
@@ -1,254 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { ContextTag } from '@status-im/components'
|
||||
import { CheckIcon, NegativeStateIcon, PendingIcon } from '@status-im/icons/12'
|
||||
import { ExternalIcon, ReceiveIcon, SendIcon } from '@status-im/icons/20'
|
||||
import { cx } from 'class-variance-authority'
|
||||
import { formatRelative } from 'date-fns'
|
||||
import { match } from 'ts-pattern'
|
||||
|
||||
import erc20TokenList from '../../constants/erc20.json'
|
||||
import { CurrencyAmount } from '../currency-amount'
|
||||
import { NetworkLogo } from '../network-logo'
|
||||
import { shortenAddress } from '../shorten-address'
|
||||
import { formatTokenAmount } from '../token-amount'
|
||||
import { useInfiniteLoading } from '../../hooks/use-infinite-loading'
|
||||
import { ActivityItem } from './components/activity-item'
|
||||
|
||||
import type { ApiOutput } from '../../data'
|
||||
|
||||
const fromAddress = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'
|
||||
|
||||
type Activity = ApiOutput['activities']['activities']['activities'][0]
|
||||
|
||||
type Props = {
|
||||
export type ActivityListProps = {
|
||||
activities: Activity[]
|
||||
userAddress: string
|
||||
onLoadMore: () => void
|
||||
hasNextPage: boolean
|
||||
isLoadingMore: boolean
|
||||
}
|
||||
|
||||
function getTokenActivityLabel(activity: Activity): string {
|
||||
if (activity.category === 'erc721') {
|
||||
return '1 NFT'
|
||||
}
|
||||
const ActivityList = (props: ActivityListProps) => {
|
||||
const { activities, userAddress, onLoadMore, hasNextPage, isLoadingMore } =
|
||||
props
|
||||
|
||||
if (
|
||||
activity.category === 'erc1155' &&
|
||||
Array.isArray(activity.erc1155Metadata) &&
|
||||
activity.erc1155Metadata.length > 0
|
||||
) {
|
||||
const total = activity.erc1155Metadata.reduce(
|
||||
(sum, meta) => sum + parseInt(meta.value, 16),
|
||||
0,
|
||||
)
|
||||
// ERC1155 can have multiple NFTs or FTs, so we show the total count
|
||||
return `${total} Token${total > 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
return '1 Asset'
|
||||
}
|
||||
|
||||
const ActivityList = (props: Props) => {
|
||||
const { activities } = props
|
||||
const { endOfPageRef, isLoading } = useInfiniteLoading({
|
||||
rootMargin: '200px',
|
||||
fetchNextPage: onLoadMore,
|
||||
isFetchingNextPage: isLoadingMore,
|
||||
hasNextPage: hasNextPage,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="pb-10">
|
||||
<div className="flex min-h-[calc(100svh-362px)] w-full overflow-auto">
|
||||
<div className="w-full">
|
||||
{activities.map(activity => {
|
||||
return <ActivityItem key={activity.uniqueId} activity={activity} />
|
||||
return (
|
||||
<ActivityItem
|
||||
key={activity.uniqueId}
|
||||
activity={activity}
|
||||
userAddress={userAddress}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ActivityItemProps = {
|
||||
activity: Activity
|
||||
}
|
||||
|
||||
const ActivityItem = (props: ActivityItemProps) => {
|
||||
const { activity } = props
|
||||
|
||||
const outgoingTransaction = activity.from === fromAddress
|
||||
const assetSymbol =
|
||||
activity.asset || (activity.category === 'external' ? 'ETH' : null)
|
||||
const eurValue = Number(activity.eurRate) * Number(activity.value)
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[2fr_1fr_1fr] gap-8 p-3 transition-colors focus-within:bg-neutral-5 hover:bg-neutral-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<TokenLogo
|
||||
symbol={assetSymbol || 'ETH'}
|
||||
address={activity.rawContract?.address ?? ''}
|
||||
/>
|
||||
<div className="absolute bottom-[-4px] right-[-4px] size-[18px] rounded-full border border-white-100">
|
||||
<NetworkLogo name={activity.network} size={16} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2 text-15 font-600 sm:max-w-full">
|
||||
<ActivityDirection
|
||||
direction={outgoingTransaction ? 'sent' : 'received'}
|
||||
/>
|
||||
<span className="text-13 font-400 text-neutral-40">
|
||||
{formatRelative(
|
||||
new Date(activity.metadata.blockTimestamp),
|
||||
new Date(),
|
||||
{hasNextPage && (
|
||||
<div
|
||||
ref={endOfPageRef}
|
||||
className="flex h-20 items-center justify-center"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="text-13 font-400 text-neutral-50">
|
||||
Loading more activities...
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-1" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-end text-13 font-400 text-neutral-50">
|
||||
<ContextTag type="label" size="24">
|
||||
{shortenAddress(
|
||||
outgoingTransaction ? activity.to : activity.from,
|
||||
)}
|
||||
</ContextTag>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasNextPage && activities.length > 0 && (
|
||||
<div className="py-8 text-center text-13 font-400 text-neutral-40">
|
||||
No more activities to load
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end justify-center">
|
||||
{assetSymbol ? (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<ContextTag type="label" size="24">
|
||||
{`${
|
||||
outgoingTransaction ? '-' : '+'
|
||||
} ${formatTokenAmount(activity.value, 'precise')} ${assetSymbol}`}
|
||||
</ContextTag>
|
||||
{eurValue > 0 && (
|
||||
<div
|
||||
className={cx(
|
||||
'flex items-center gap-1 text-13 font-500',
|
||||
outgoingTransaction ? 'text-danger-50' : 'text-success-50',
|
||||
)}
|
||||
>
|
||||
{outgoingTransaction ? '-' : '+'}
|
||||
<CurrencyAmount
|
||||
value={eurValue}
|
||||
format="standard"
|
||||
className="text-13 font-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ContextTag type="label" size="24">
|
||||
{getTokenActivityLabel(activity)}
|
||||
</ContextTag>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end text-13 font-400 text-neutral-50">
|
||||
<a
|
||||
href={`https://etherscan.io/tx/${activity.hash}`}
|
||||
target="_blank"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<ActivityStatus status={activity.status} />
|
||||
<ExternalIcon className="text-neutral-50" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ActivityStatusProps = {
|
||||
status: Activity['status']
|
||||
}
|
||||
|
||||
const ActivityStatus = (props: ActivityStatusProps) => {
|
||||
const { status } = props
|
||||
|
||||
const baseClass =
|
||||
'flex items-center gap-1 rounded-20 border pl-[5px] pr-[8px] py-[3px] h-6'
|
||||
|
||||
return match(status)
|
||||
.with('success', () => {
|
||||
return (
|
||||
<div className={cx(baseClass, 'border-success-50 bg-success-50/10')}>
|
||||
<CheckIcon className="text-success-50" />
|
||||
<div className="text-13 font-500 text-success-50">Success</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
.with('failed', () => {
|
||||
return (
|
||||
<div className={cx(baseClass, 'border-danger-50/20 bg-danger-50/10')}>
|
||||
<NegativeStateIcon className="text-danger-50" />
|
||||
<div className="text-13 font-500 text-danger-50">Failed</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
.with('pending', () => {
|
||||
return (
|
||||
<div className={cx(baseClass, 'border-neutral-20 bg-neutral-10')}>
|
||||
<PendingIcon className="text-neutral-40" />
|
||||
<div className="flex text-13 font-400 text-neutral-40">Pending</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
.with('unknown', () => {
|
||||
return (
|
||||
<div className={cx(baseClass, 'border-neutral-20 bg-neutral-10')}>
|
||||
<div className="flex text-13 font-400 text-neutral-50">Unknown</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
.exhaustive()
|
||||
}
|
||||
|
||||
type TokenLogoProps = {
|
||||
symbol: string
|
||||
address: string
|
||||
}
|
||||
|
||||
const TokenLogo = (props: TokenLogoProps) => {
|
||||
const { symbol, address } = props
|
||||
|
||||
const getTokenLogo = (symbol: string, contractAddress?: string) => {
|
||||
if (symbol === 'ETH') {
|
||||
return 'https://assets.coingecko.com/coins/images/279/large/ethereum.png'
|
||||
}
|
||||
|
||||
const token = erc20TokenList.tokens.find(
|
||||
token =>
|
||||
token.symbol === symbol ||
|
||||
(contractAddress &&
|
||||
token.address.toLowerCase() === contractAddress.toLowerCase()),
|
||||
)
|
||||
|
||||
return token?.logoURI ?? ''
|
||||
}
|
||||
|
||||
const src = getTokenLogo(symbol, address)
|
||||
|
||||
if (!src) {
|
||||
return (
|
||||
<div className="flex size-8 flex-shrink-0 items-center justify-center rounded-full bg-neutral-10 text-11 font-600 text-neutral-50">
|
||||
{symbol.slice(0, 4).toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
className="size-8 flex-shrink-0 rounded-full bg-neutral-10"
|
||||
alt={symbol}
|
||||
src={src}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type ActivityDirectionProps = {
|
||||
direction: 'sent' | 'received'
|
||||
}
|
||||
|
||||
const ActivityDirection = (props: ActivityDirectionProps) => {
|
||||
const { direction } = props
|
||||
const Icon = direction === 'sent' ? SendIcon : ReceiveIcon
|
||||
const text = direction === 'sent' ? 'Sent' : 'Received'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-15 font-600 text-neutral-100">
|
||||
<Icon className="text-neutral-50" />
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type Props = {
|
||||
|
||||
// TODO: get this from the user's settings
|
||||
const SYMBOL: 'EUR' | 'USD' = 'EUR'
|
||||
const MIN_VALUE = 0.01
|
||||
|
||||
export const CurrencyAmount = (props: Props) => {
|
||||
const { value, format = 'standard', className } = props
|
||||
@@ -61,5 +62,9 @@ export const CurrencyAmount = (props: Props) => {
|
||||
[format],
|
||||
)
|
||||
|
||||
if (value > 0 && value < MIN_VALUE && format !== 'precise') {
|
||||
return <div className={className}>{'< ' + formatter.format(MIN_VALUE)}</div>
|
||||
}
|
||||
|
||||
return <div className={className}>{formatter.format(value)}</div>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export type * from '../types'
|
||||
export * from '../utils/variants'
|
||||
export { AccountMenu } from './account-menu'
|
||||
export { ActivityList } from './activity-list'
|
||||
export { ActivityList, type ActivityListProps } from './activity-list'
|
||||
export { type Account, Address, type AddressProps } from './address'
|
||||
export { AssetsList } from './assets-list'
|
||||
export { Balance } from './balance'
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { format, isToday, isYesterday } from 'date-fns'
|
||||
|
||||
type Props = {
|
||||
timestamp: Date | string | number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const RelativeDate = (props: Props) => {
|
||||
const { timestamp, className } = props
|
||||
const date = new Date(timestamp)
|
||||
|
||||
const formatDate = () => {
|
||||
if (isToday(date)) {
|
||||
return `Today ${format(date, 'HH:mm')}`
|
||||
}
|
||||
if (isYesterday(date)) {
|
||||
return `Yesterday ${format(date, 'HH:mm')}`
|
||||
}
|
||||
return format(date, 'dd MMMM HH:mm')
|
||||
}
|
||||
|
||||
return <span className={className}>{formatDate()}</span>
|
||||
}
|
||||
@@ -41,6 +41,32 @@ const cachedPriceData = cache(async (key: string) => {
|
||||
return await fetchTokensPriceForDate(symbols, timestamp)
|
||||
})
|
||||
|
||||
type PriceDataResponse = Record<string, { EUR?: { PRICE: number } }>
|
||||
|
||||
const batchPriceRequests = async (
|
||||
priceRequests: Array<{ symbols: string[]; timestamp: number }>,
|
||||
): Promise<Record<string, PriceDataResponse>> => {
|
||||
const BATCH_SIZE = 5
|
||||
const results: Record<string, PriceDataResponse> = {}
|
||||
|
||||
for (let i = 0; i < priceRequests.length; i += BATCH_SIZE) {
|
||||
const batch = priceRequests.slice(i, i + BATCH_SIZE)
|
||||
|
||||
const batchPromises = batch.map(async ({ symbols, timestamp }) => {
|
||||
const key = JSON.stringify({ symbols, timestamp })
|
||||
const data = await cachedPriceData(key)
|
||||
return { key, data }
|
||||
})
|
||||
|
||||
const batchResults = await Promise.all(batchPromises)
|
||||
batchResults.forEach(({ key, data }) => {
|
||||
results[key] = data
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
const cachedActivity = cache(async (key: string) => {
|
||||
const { address, network } = JSON.parse(key)
|
||||
return await activity(address, network)
|
||||
@@ -178,9 +204,13 @@ export async function page({
|
||||
{},
|
||||
)
|
||||
|
||||
const enhancedActivities: Activity[] = []
|
||||
const priceRequests: Array<{
|
||||
symbols: string[]
|
||||
timestamp: number
|
||||
date: string
|
||||
}> = []
|
||||
|
||||
for (const dateActivities of Object.values(activityGroups)) {
|
||||
for (const [date, dateActivities] of Object.entries(activityGroups)) {
|
||||
const allAssetSymbols = [
|
||||
...new Set(
|
||||
dateActivities.map(activity => activity.asset).filter(Boolean),
|
||||
@@ -204,9 +234,24 @@ export async function page({
|
||||
new Date(dateActivities[0].metadata.blockTimestamp).getTime() / 1000,
|
||||
)
|
||||
|
||||
const priceData = await cachedPriceData(
|
||||
JSON.stringify({ symbols: validAssetSymbols, timestamp }),
|
||||
)
|
||||
if (validAssetSymbols.length > 0) {
|
||||
priceRequests.push({ symbols: validAssetSymbols, timestamp, date })
|
||||
}
|
||||
}
|
||||
|
||||
const batchedPriceData = await batchPriceRequests(priceRequests)
|
||||
|
||||
const enhancedActivities: Activity[] = []
|
||||
for (const [date, dateActivities] of Object.entries(activityGroups)) {
|
||||
const requestKey = priceRequests.find(req => req.date === date)
|
||||
const priceData = requestKey
|
||||
? batchedPriceData[
|
||||
JSON.stringify({
|
||||
symbols: requestKey.symbols,
|
||||
timestamp: requestKey.timestamp,
|
||||
})
|
||||
] || {}
|
||||
: {}
|
||||
|
||||
const dateEnhancedActivities = dateActivities.map(activity => {
|
||||
const assetSymbol =
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ApiRouter } from '.'
|
||||
import type { Activity } from './routers/activity'
|
||||
import type { Collectible } from './routers/collectibles'
|
||||
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server'
|
||||
|
||||
@@ -15,4 +16,4 @@ export type NetworkType =
|
||||
| 'polygon'
|
||||
| 'bsc'
|
||||
|
||||
export type { Collectible }
|
||||
export type { Activity, Collectible }
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export { type ApiRouter, apiRouter } from './api'
|
||||
export type { ApiInput, ApiOutput, Collectible, NetworkType } from './api/types'
|
||||
export type {
|
||||
Activity,
|
||||
ApiInput,
|
||||
ApiOutput,
|
||||
Collectible,
|
||||
NetworkType,
|
||||
} from './api/types'
|
||||
export { createAPI } from './trpc/api'
|
||||
|
||||
@@ -13,6 +13,7 @@ import { serverEnv } from '../../../config/env.server.mjs'
|
||||
|
||||
import type { NetworkType } from '../../api/types'
|
||||
import type {
|
||||
AssetTransfer,
|
||||
deprecated_NFTSaleResponseBody,
|
||||
ERC20TokenBalanceResponseBody,
|
||||
NativeTokenBalanceResponseBody,
|
||||
@@ -540,46 +541,155 @@ export async function getIncomingAssetTransfers(
|
||||
}
|
||||
}
|
||||
|
||||
type TransferBuffer = {
|
||||
incoming: AssetTransfer[]
|
||||
outgoing: AssetTransfer[]
|
||||
incomingPageKey?: string
|
||||
outgoingPageKey?: string
|
||||
hasReachedEnd: {
|
||||
incoming: boolean
|
||||
outgoing: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const transferBuffers = new Map<string, TransferBuffer>()
|
||||
|
||||
export async function getAssetTransfers(
|
||||
address: string,
|
||||
network: NetworkType,
|
||||
pageKey?: string,
|
||||
limit: number = 100,
|
||||
limit: number = 20,
|
||||
) {
|
||||
const outgoingResult = await getOutgoingAssetTransfers(
|
||||
address,
|
||||
network,
|
||||
pageKey,
|
||||
Math.ceil(limit / 2),
|
||||
)
|
||||
try {
|
||||
const cacheKey = `${address.toLowerCase()}-${network}`
|
||||
let buffer = transferBuffers.get(cacheKey)
|
||||
|
||||
const incomingResult = await getIncomingAssetTransfers(
|
||||
address,
|
||||
network,
|
||||
pageKey,
|
||||
Math.ceil(limit / 2),
|
||||
)
|
||||
if (!pageKey || !buffer) {
|
||||
buffer = {
|
||||
incoming: [],
|
||||
outgoing: [],
|
||||
hasReachedEnd: { incoming: false, outgoing: false },
|
||||
}
|
||||
transferBuffers.set(cacheKey, buffer)
|
||||
}
|
||||
|
||||
const allTransfers = [
|
||||
...outgoingResult.transfers,
|
||||
...incomingResult.transfers,
|
||||
]
|
||||
allTransfers.sort((a, b) => {
|
||||
// First sort by block number (newest first)
|
||||
const blockDiff = parseInt(b.blockNum, 16) - parseInt(a.blockNum, 16)
|
||||
if (blockDiff !== 0) return blockDiff
|
||||
let incomingPageKey: string | undefined
|
||||
let outgoingPageKey: string | undefined
|
||||
|
||||
// If same block, sort by timestamp (newest first)
|
||||
const timestampA = new Date(a.metadata.blockTimestamp).getTime()
|
||||
const timestampB = new Date(b.metadata.blockTimestamp).getTime()
|
||||
return timestampB - timestampA
|
||||
})
|
||||
if (pageKey) {
|
||||
try {
|
||||
const parsed = JSON.parse(pageKey)
|
||||
if (parsed) {
|
||||
incomingPageKey = parsed.incoming
|
||||
outgoingPageKey = parsed.outgoing
|
||||
}
|
||||
} catch {
|
||||
incomingPageKey = buffer.incomingPageKey
|
||||
outgoingPageKey = buffer.outgoingPageKey
|
||||
}
|
||||
}
|
||||
|
||||
const transfers = allTransfers.slice(0, limit)
|
||||
const needsFetch =
|
||||
buffer.incoming.length < limit || buffer.outgoing.length < limit
|
||||
|
||||
return {
|
||||
transfers,
|
||||
pageKey: outgoingResult.pageKey || incomingResult.pageKey,
|
||||
if (
|
||||
needsFetch &&
|
||||
(!buffer.hasReachedEnd.incoming || !buffer.hasReachedEnd.outgoing)
|
||||
) {
|
||||
const isOutgoingFetching =
|
||||
!buffer.hasReachedEnd.outgoing && buffer.outgoing.length < limit
|
||||
const isIncomingFetching =
|
||||
!buffer.hasReachedEnd.incoming && buffer.incoming.length < limit
|
||||
|
||||
const [outgoingResult, incomingResult] = await Promise.all([
|
||||
isOutgoingFetching
|
||||
? getOutgoingAssetTransfers(
|
||||
address,
|
||||
network,
|
||||
outgoingPageKey || buffer.outgoingPageKey,
|
||||
limit,
|
||||
)
|
||||
: { transfers: [], pageKey: undefined },
|
||||
isIncomingFetching
|
||||
? getIncomingAssetTransfers(
|
||||
address,
|
||||
network,
|
||||
incomingPageKey || buffer.incomingPageKey,
|
||||
limit,
|
||||
)
|
||||
: { transfers: [], pageKey: undefined },
|
||||
])
|
||||
|
||||
buffer.incoming.push(
|
||||
...incomingResult.transfers.map(t => ({
|
||||
...t,
|
||||
tokenId: t.tokenId ?? undefined,
|
||||
erc721TokenId: t.erc721TokenId ?? undefined,
|
||||
erc1155Metadata: t.erc1155Metadata ?? undefined,
|
||||
})),
|
||||
)
|
||||
buffer.outgoing.push(
|
||||
...outgoingResult.transfers.map(t => ({
|
||||
...t,
|
||||
tokenId: t.tokenId ?? undefined,
|
||||
erc721TokenId: t.erc721TokenId ?? undefined,
|
||||
erc1155Metadata: t.erc1155Metadata ?? undefined,
|
||||
})),
|
||||
)
|
||||
|
||||
buffer.incomingPageKey = incomingResult.pageKey
|
||||
buffer.outgoingPageKey = outgoingResult.pageKey
|
||||
buffer.hasReachedEnd.incoming = incomingResult.pageKey === undefined
|
||||
buffer.hasReachedEnd.outgoing = outgoingResult.pageKey === undefined
|
||||
}
|
||||
|
||||
const allTransfers = [...buffer.incoming, ...buffer.outgoing]
|
||||
const transfersMap = new Map<string, AssetTransfer>()
|
||||
for (const transfer of allTransfers) {
|
||||
transfersMap.set(transfer.uniqueId, transfer)
|
||||
}
|
||||
|
||||
const uniqueTransfers = Array.from(transfersMap.values())
|
||||
|
||||
uniqueTransfers.sort((a, b) => {
|
||||
const blockDiff = parseInt(b.blockNum, 16) - parseInt(a.blockNum, 16)
|
||||
if (blockDiff !== 0) return blockDiff
|
||||
|
||||
const timestampA = new Date(a.metadata.blockTimestamp).getTime()
|
||||
const timestampB = new Date(b.metadata.blockTimestamp).getTime()
|
||||
return timestampB - timestampA
|
||||
})
|
||||
|
||||
const transfers = uniqueTransfers.slice(0, limit)
|
||||
|
||||
const consumedIds = new Set(transfers.map(t => t.uniqueId))
|
||||
buffer.incoming = buffer.incoming.filter(t => !consumedIds.has(t.uniqueId))
|
||||
buffer.outgoing = buffer.outgoing.filter(t => !consumedIds.has(t.uniqueId))
|
||||
|
||||
const hasMoreTransfers =
|
||||
!buffer.hasReachedEnd.incoming ||
|
||||
!buffer.hasReachedEnd.outgoing ||
|
||||
buffer.incoming.length > 0 ||
|
||||
buffer.outgoing.length > 0
|
||||
|
||||
let nextPageKey: string | undefined
|
||||
if (hasMoreTransfers) {
|
||||
nextPageKey = JSON.stringify({
|
||||
incoming: buffer.incomingPageKey,
|
||||
outgoing: buffer.outgoingPageKey,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
transfers,
|
||||
pageKey: nextPageKey,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('getAssetTransfers error:', error)
|
||||
return {
|
||||
transfers: [],
|
||||
pageKey: undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user