+
+
>
diff --git a/apps/extension/src/ui/domains/Asset/AssetPrice.tsx b/apps/extension/src/ui/domains/Asset/AssetPrice.tsx
new file mode 100644
index 0000000000..c0976ccafb
--- /dev/null
+++ b/apps/extension/src/ui/domains/Asset/AssetPrice.tsx
@@ -0,0 +1,86 @@
+import { bind } from "@react-rxjs/core"
+import { TokenId } from "@talismn/chaindata-provider"
+import { classNames, formatPrice } from "@talismn/util"
+import { FC } from "react"
+import { combineLatest, map } from "rxjs"
+import { Tooltip, TooltipContent, TooltipTrigger } from "talisman-ui"
+
+import { getTokenRates$, selectedCurrency$ } from "@ui/state"
+
+const [useDisplayAssetPrice] = bind((tokenId: TokenId | null | undefined) =>
+ combineLatest([getTokenRates$(tokenId), selectedCurrency$]).pipe(
+ map(([rates, currency]) => {
+ const rate = rates?.[currency]
+ if (!rate) return null
+
+ const compact = formatPrice(rate.price, currency, true)
+
+ const full = formatPrice(rate.price, currency, false)
+
+ const rawChange24h = rate.change24h
+ ? new Intl.NumberFormat(undefined, {
+ minimumFractionDigits: 1,
+ style: "percent",
+ signDisplay: "always",
+ }).format(rate.change24h / 100)
+ : undefined
+
+ // we dont want a sign (which is used for color check) if change displays as +0.0% or -0.0%
+ const change24h = rawChange24h?.length
+ ? rawChange24h.slice(1) === "0.0%"
+ ? "0.0%"
+ : rawChange24h
+ : undefined
+
+ return {
+ compact,
+ full,
+ change24h,
+ }
+ }),
+ ),
+)
+
+export const AssetPrice: FC<{
+ tokenId: TokenId | null | undefined
+ as?: "div" | "span"
+ className?: string
+ priceClassName?: string
+ changeClassName?: string
+ noTooltip?: boolean
+ noChange?: boolean
+}> = ({
+ as: Container = "div",
+ tokenId,
+ noTooltip,
+ noChange,
+ className,
+ priceClassName,
+ changeClassName,
+}) => {
+ const price = useDisplayAssetPrice(tokenId)
+
+ if (!price) return null
+
+ return (
+
+
+
+ {price.compact}
+ {!noChange && price.change24h ? (
+
+ {price.change24h}
+
+ ) : null}
+
+
+ {!noTooltip && {price.full}}
+
+ )
+}
diff --git a/apps/extension/src/ui/domains/Asset/AssetPriceChart.tsx b/apps/extension/src/ui/domains/Asset/AssetPriceChart.tsx
new file mode 100644
index 0000000000..19e37a1e5a
--- /dev/null
+++ b/apps/extension/src/ui/domains/Asset/AssetPriceChart.tsx
@@ -0,0 +1,584 @@
+import { Token, TokenId } from "@talismn/chaindata-provider"
+import { CheckIcon, ChevronDownIcon, ExternalLinkIcon } from "@talismn/icons"
+import { TokenRateCurrency } from "@talismn/token-rates"
+import { classNames, formatPrice } from "@talismn/util"
+import { useQuery } from "@tanstack/react-query"
+import ChartJs, { ActiveElement, ChartComponentLike, ChartEvent } from "chart.js/auto"
+import { fetchFromCoingecko } from "extension-core"
+import { log } from "extension-shared"
+import { uniq } from "lodash"
+import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { useTranslation } from "react-i18next"
+import { IconButton, Popover, PopoverContent, PopoverTrigger, usePopoverContext } from "talisman-ui"
+
+import { useSelectedCurrency, useTokenRates, useTokenRatesMap, useTokensMap } from "@ui/state"
+
+import { AssetPrice } from "./AssetPrice"
+import { TokenLogo } from "./TokenLogo"
+
+type ChartVariant = "small" | "large"
+
+export const AssetPriceChart: FC<{
+ tokenIds: TokenId[]
+ variant: ChartVariant
+ className?: string
+}> = ({ tokenIds, variant, className }) => {
+ const { t } = useTranslation()
+ const currency = useSelectedCurrency()
+ const tokensMap = useTokensMap()
+ const tokensWithCoingeckoId = useMemo(
+ () => tokenIds.map((id) => tokensMap[id]).filter((t) => !!t?.coingeckoId),
+ [tokenIds, tokensMap],
+ )
+
+ const selectedCurrency = useSelectedCurrency()
+ const tokenRates = useTokenRatesMap()
+
+ // we want user to select a coingecko token, but we dont have this kind of object, so select a token and make sure each one is mapped to a unique coingeckoId
+ const selectableTokens = useMemo(() => {
+ if (!tokenRates) return []
+
+ const tokens = uniq((tokensWithCoingeckoId || []).map((t) => t.coingeckoId))
+ .filter(Boolean)
+ .map((coingeckoId) => tokensWithCoingeckoId.find((t) => t.coingeckoId === coingeckoId))
+ .filter(Boolean) as Token[]
+
+ return tokens.sort((a, b) => {
+ // sort by descending market cap
+ const mc1 = tokenRates[a.id]?.[selectedCurrency]?.marketCap ?? 0
+ const mc2 = tokenRates[b.id]?.[selectedCurrency]?.marketCap ?? 0
+ return mc2 - mc1
+ })
+ }, [selectedCurrency, tokenRates, tokensWithCoingeckoId])
+
+ const [selectedTokenId, setSelectedTokenId] = useState
(
+ selectableTokens[0]?.id ?? null,
+ )
+ useEffect(() => {
+ // workaround empty button when changing account to one that doesnt have balance for the selecte done
+ if (!selectableTokens.find((t) => t.id === selectedTokenId) && selectableTokens.length)
+ setSelectedTokenId(selectableTokens[0].id)
+ }, [selectedTokenId, selectableTokens])
+
+ const coingeckoId = useMemo(
+ () => tokensWithCoingeckoId.find((t) => t.id === selectedTokenId)?.coingeckoId ?? null,
+ [selectedTokenId, tokensWithCoingeckoId],
+ )
+
+ const [timespan, setTimespan] = useState("D")
+
+ const { data: prices, refetch } = useMarketChart(coingeckoId, currency, timespan)
+
+ useEffect(() => {
+ // update graph if tokenRates data changes
+ log.debug("AssetPriceGraph refetch")
+ refetch()
+ }, [tokenRates, refetch])
+
+ const handleCoingeckoClick = useCallback(() => {
+ if (!coingeckoId) return
+
+ window.open(
+ `https://www.coingecko.com/en/coins/${coingeckoId}`,
+ "_blank",
+ "noopener noreferrer",
+ )
+ }, [coingeckoId])
+
+ const [hoveredValue, setHoveredValue] = useState(null)
+ const formattedHoveredValue = useMemo(() => {
+ return typeof hoveredValue === "number" ? formatPrice(hoveredValue, currency, true) : null
+ }, [hoveredValue, currency])
+
+ // Note : if prices array has only 1 entry, the token is in preview mode and we should not render the chart nor the price
+ const isValid = useMemo(() => !prices || prices.length > 1, [prices])
+
+ if (!selectedTokenId || !selectableTokens.length) return null
+
+ return (
+
+
+
+
+ {isValid && (
+
+ {formattedHoveredValue ??
}
+
+ )}
+
+
+
+
+
+
+ {isValid && (
+ <>
+
+ {!!prices && (
+
+ )}
+ {/* use absolute position for buttons, above the graph, to not break the gradient */}
+
+
+
+
+ >
+ )}
+ {!isValid && (
+
+ {t("No price history")}
+
+ )}
+
+ )
+}
+
+type ChartSpan = "H" | "D" | "W" | "M" | "Y" | "A"
+
+type ChartSpanConfig = {
+ label: string
+ days: string // number as string, also supports "max"
+ time: boolean
+}
+
+const CHART_TIMESPANS: Record = {
+ H: {
+ label: "1H",
+ days: "1",
+ time: true,
+ },
+ D: {
+ label: "1D",
+ days: "2",
+ time: true,
+ },
+ W: {
+ label: "1W",
+ days: "7",
+ time: true,
+ },
+ M: {
+ label: "1M",
+ days: "30",
+ time: false,
+ },
+ Y: {
+ label: "1Y",
+ days: "365",
+ time: false,
+ },
+ A: {
+ label: "ALL",
+ days: "max",
+ time: false,
+ },
+}
+
+const useMarketChart = (
+ coingeckoId: string | null,
+ currency: TokenRateCurrency,
+ timespan: ChartSpan,
+) => {
+ return useQuery({
+ queryKey: ["priceChart", coingeckoId, currency, timespan],
+ queryFn: async () => {
+ if (!coingeckoId) return null
+ const config = CHART_TIMESPANS[timespan]
+
+ const query = new URLSearchParams({
+ vs_currency: currency,
+ days: config.days,
+ })
+
+ const result = await fetchFromCoingecko(
+ `/api/v3/coins/${coingeckoId}/market_chart?${query.toString()}`,
+ )
+ if (!result.ok) throw new Error("Failed to fetch market chart for " + coingeckoId)
+
+ return result.json() as Promise<{ prices: [number, number][] }>
+ },
+ select: (data) => {
+ switch (timespan) {
+ case "H":
+ return data?.prices.slice(-13) // interval is 5m, keep last 12 entries + current price
+ case "D":
+ return data?.prices.slice(-25) // interval is 1h, keep list 24 entries + current price
+ default:
+ return data?.prices // interval is 1d
+ }
+ },
+ })
+}
+
+const verticalLinePlugin: ChartComponentLike = {
+ id: "verticalLine",
+ afterDatasetsDraw(chart) {
+ const { ctx, tooltip, chartArea } = chart
+
+ if (tooltip && tooltip.opacity !== 0) {
+ ctx.save()
+ ctx.beginPath()
+ ctx.setLineDash([5, 5])
+ ctx.strokeStyle = "rgba(213, 255, 92, 0.5)"
+ ctx.lineWidth = 1
+ ctx.moveTo(tooltip.caretX, tooltip.y + tooltip.height + 5) // start below the tooltip
+ ctx.lineTo(tooltip.caretX, chartArea.bottom)
+ ctx.stroke()
+ ctx.restore()
+ }
+ },
+}
+
+ChartJs.register(verticalLinePlugin)
+
+const Chart: FC<{
+ prices: [number, number][]
+ timespan: ChartSpan
+ variant: ChartVariant
+ onHoverValueChange: (price: number | null) => void
+}> = ({ prices, timespan, variant, onHoverValueChange }) => {
+ const refChart = useRef(null)
+ const currency = useSelectedCurrency()
+
+ useEffect(() => {
+ const canvas = refChart.current
+ if (!canvas) return
+
+ const ctx = canvas.getContext("2d")
+ if (!ctx) return
+
+ // set min boundaries for y axis to ensure that timespan selector isn't drawn on the price line
+ const allPrices = prices.map(([, price]) => price)
+ const minPrice = Math.min(...allPrices)
+ const maxPrice = Math.max(...allPrices)
+ const suggestedMin = minPrice - (maxPrice - minPrice) * 0.25
+
+ // sometimes chart's onHover is called after mouse has left the canvas, so we need to track this
+ let isHovering = false
+
+ const onMouseEnter = () => {
+ isHovering = true
+ }
+ const onMouseLeave = () => {
+ isHovering = false
+ onHoverValueChange(null)
+ }
+
+ canvas.addEventListener("mouseenter", onMouseEnter)
+ canvas.addEventListener("mouseleave", onMouseLeave)
+
+ const onHover = (event: ChartEvent, elements: ActiveElement[]) => {
+ if (!isHovering || !elements.length) return
+ try {
+ const element = elements[0]
+ const price = allPrices[element.index]
+ onHoverValueChange(price)
+ } catch (e) {
+ log.warn("Failed to read hovered price", { event, elements })
+ onHoverValueChange(null)
+ }
+ }
+
+ // Create a gradient
+ const gradient = ctx.createLinearGradient(0, 0, 0, ctx.canvas.height)
+ gradient.addColorStop(0, "rgba(213, 255, 92, 0.2)") // Start color (top)
+ gradient.addColorStop(1, "rgba(213, 255, 92, 0)") // End color (bottom)
+
+ const chart = new ChartJs(canvas, {
+ type: "line",
+ options: {
+ onHover,
+ maintainAspectRatio: false,
+ responsive: true,
+ animation: false,
+ layout: {
+ padding: {
+ top: 0,
+ bottom: 0,
+ left: 0,
+ right: 0,
+ },
+ },
+ interaction: {
+ // controls activeElements for onHover
+ mode: "index",
+ intersect: false,
+ },
+ plugins: {
+ legend: {
+ display: false,
+ },
+ tooltip: {
+ enabled: true,
+ mode: "index",
+ intersect: false,
+ displayColors: false,
+ backgroundColor: "#2E3221",
+ titleColor: "#d5ff5c",
+ titleFont: {
+ size: variant === "large" ? 14 : 12,
+ weight: 400,
+ },
+ titleMarginBottom: 0,
+ caretSize: 0,
+ caretPadding: 40,
+ yAlign: "bottom",
+ callbacks: {
+ title: function (tooltipItems) {
+ const date = new Date(tooltipItems[0].label)
+ return CHART_TIMESPANS[timespan].time
+ ? `${date.toLocaleDateString(undefined, { dateStyle: "short" })} ${date.toLocaleTimeString(undefined, { timeStyle: "short" })}`
+ : date.toLocaleDateString(undefined, { dateStyle: "short" })
+ },
+ label: function () {
+ return ""
+ },
+ },
+ },
+ },
+ scales: {
+ x: {
+ ticks: {
+ display: false,
+ align: "start",
+ },
+ grid: {
+ display: false,
+ drawTicks: false,
+ },
+ },
+ y: {
+ suggestedMin,
+ ticks: {
+ display: false,
+ align: "start",
+ },
+ grid: {
+ display: false,
+ drawTicks: false,
+ },
+ },
+ },
+ },
+
+ data: {
+ labels: prices.map(([timestamp]) => new Date(timestamp)),
+ datasets: [
+ {
+ label: "Price",
+ data: allPrices,
+ borderColor: "#d5ff5c",
+ pointRadius: 0,
+ tension: 0.1,
+ fill: true,
+ backgroundColor: gradient,
+ borderWidth: 2,
+ },
+ ],
+ },
+ })
+
+ return () => {
+ canvas.removeEventListener("mouseenter", onMouseEnter)
+ canvas.removeEventListener("mouseleave", onMouseLeave)
+ chart.destroy()
+ }
+ }, [currency, onHoverValueChange, prices, refChart, timespan, variant])
+
+ return
+}
+
+const TimespanSelect: FC<{
+ value: ChartSpan
+ variant: ChartVariant
+ onChange: (value: ChartSpan) => void
+ className?: string
+}> = ({ value, variant, onChange, className }) => {
+ return (
+
+ {Object.entries(CHART_TIMESPANS).map(([key, { label }]) => (
+
+ ))}
+
+ )
+}
+
+const TokenSelect: FC<{
+ tokens: Token[]
+ value: TokenId
+ variant: ChartVariant
+ onChange: (tokenId: TokenId) => void
+}> = ({ tokens, value, variant, onChange }) => {
+ const token = useMemo(() => tokens.find((t) => t.id === value), [tokens, value])
+
+ if (!token || !tokens.length) return null
+
+ if (tokens.length === 1)
+ return (
+
+ )
+
+ return (
+
+
+
+
+
+
+ {tokens.map((t) => (
+ onChange(t.id)}
+ />
+ ))}
+
+
+
+ )
+}
+
+const TokenSelectOption: FC<{ token: Token; selected: boolean; onClick: () => void }> = ({
+ token,
+ selected,
+ onClick,
+}) => {
+ const { t } = useTranslation()
+ const { setOpen } = usePopoverContext()
+
+ const handleClick = useCallback(() => {
+ onClick()
+ setOpen(false)
+ }, [setOpen, onClick])
+
+ return (
+
+ )
+}
+
+const MarketCap: FC<{ tokenId: TokenId }> = ({ tokenId }) => {
+ const { t } = useTranslation()
+ const tokenRates = useTokenRates(tokenId)
+ const currency = useSelectedCurrency()
+
+ const display = useMemo(
+ () =>
+ tokenRates?.[currency]?.marketCap
+ ? new Intl.NumberFormat(undefined, {
+ maximumSignificantDigits: 4,
+ style: "currency",
+ currency,
+ currencyDisplay: currency === "usd" ? "narrowSymbol" : "symbol",
+ notation: "compact",
+ }).format(tokenRates[currency].marketCap)
+ : t("unknown"),
+ [tokenRates, currency, t],
+ )
+
+ return {display}
+}
diff --git a/apps/extension/src/ui/domains/Portfolio/AssetDetails/DashboardAssetDetails.tsx b/apps/extension/src/ui/domains/Portfolio/AssetDetails/DashboardAssetDetails.tsx
index 4c22041eb3..9de4ab6091 100644
--- a/apps/extension/src/ui/domains/Portfolio/AssetDetails/DashboardAssetDetails.tsx
+++ b/apps/extension/src/ui/domains/Portfolio/AssetDetails/DashboardAssetDetails.tsx
@@ -1,4 +1,4 @@
-import { ChainId, EvmNetworkId, TokenId } from "@talismn/chaindata-provider"
+import { TokenId } from "@talismn/chaindata-provider"
import { ZapOffIcon } from "@talismn/icons"
import { classNames } from "@talismn/util"
import { formatDuration, intervalToDuration } from "date-fns"
@@ -27,7 +27,7 @@ import { PortfolioAccount } from "./PortfolioAccount"
import { SendFundsButton } from "./SendFundsIconButton"
import { TokenContextMenu } from "./TokenContextMenu"
import { useAssetDetails } from "./useAssetDetails"
-import { DetailRow, useChainTokenBalances } from "./useChainTokenBalances"
+import { BalanceDetailRow, useTokenBalances } from "./useTokenBalances"
import { useUniswapV2BalancePair } from "./useUniswapV2BalancePair"
const AssetState = ({
@@ -72,18 +72,15 @@ const AssetState = ({
)
}
-type AssetRowProps = {
- chainId: ChainId | EvmNetworkId
- balances: Balances
-}
-
-const ChainTokenBalances = ({ chainId, balances }: AssetRowProps) => {
+const TokenBalances: FC<{ tokenId: TokenId; balances: Balances }> = ({ tokenId, balances }) => {
const { t } = useTranslation()
- const { chainOrNetwork, summary, symbol, tokenId, detailRows, status, networkType } =
- useChainTokenBalances({ chainId, balances })
+ const { chainOrNetwork, summary, token, detailRows, status, networkType } = useTokenBalances({
+ tokenId,
+ balances,
+ })
// wait for data to load
- if (!chainOrNetwork || !summary || !symbol || balances.count === 0) return null
+ if (!chainOrNetwork || !summary || !token || balances.count === 0) return null
const isUniswapV2LpToken = balances.sorted[0]?.source === "evm-uniswapv2"
@@ -105,7 +102,7 @@ const ChainTokenBalances = ({ chainId, balances }: AssetRowProps) => {
{chainOrNetwork.name}
}>
-
+
{tokenId && (
{
render={summary.lockedTokens.gt(0)}
tokens={summary.lockedTokens}
fiat={summary.lockedFiat}
- symbol={symbol}
+ symbol={token.symbol}
tooltip={t("Total Locked Balance")}
balancesStatus={status}
className={classNames(
@@ -138,7 +135,7 @@ const ChainTokenBalances = ({ chainId, balances }: AssetRowProps) => {
render
tokens={summary.availableTokens}
fiat={summary.availableFiat}
- symbol={symbol}
+ symbol={token.symbol}
tooltip={t("Total Available Balance")}
balancesStatus={status}
className={classNames(
@@ -166,7 +163,7 @@ const ChainTokenBalances = ({ chainId, balances }: AssetRowProps) => {
key={row.key}
row={row}
isLastRow={rows.length === i + 1}
- symbol={symbol}
+ symbol={token.symbol}
status={status}
tokenId={tokenId}
/>
@@ -251,7 +248,7 @@ const ChainTokenBalancesDetailRow = ({
symbol,
tokenId,
}: {
- row: DetailRow
+ row: BalanceDetailRow
isLastRow?: boolean
status: BalancesStatus
symbol: string
@@ -358,20 +355,18 @@ const LockedExtra: FC<{
)
}
-type AssetsTableProps = {
- balances: Balances
- symbol: string
-}
-
-export const DashboardAssetDetails = ({ balances, symbol }: AssetsTableProps) => {
- const { balancesByChain: rows } = useAssetDetails(balances)
+export const DashboardAssetDetails: FC<{ balances: Balances; symbol: string }> = ({
+ balances,
+ symbol,
+}) => {
+ const { balancesByToken } = useAssetDetails(balances)
- if (rows.length === 0) return
+ if (balancesByToken.length === 0) return
return (
- {rows.map(([chainId, bal]) => (
-
+ {balancesByToken.map(([tokenId, bal]) => (
+
))}
)
diff --git a/apps/extension/src/ui/domains/Portfolio/AssetDetails/PopupAssetDetails.tsx b/apps/extension/src/ui/domains/Portfolio/AssetDetails/PopupAssetDetails.tsx
index 80e0b7cb09..813301bb4e 100644
--- a/apps/extension/src/ui/domains/Portfolio/AssetDetails/PopupAssetDetails.tsx
+++ b/apps/extension/src/ui/domains/Portfolio/AssetDetails/PopupAssetDetails.tsx
@@ -6,7 +6,7 @@ import { FC, Suspense, useCallback, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { PillButton, Tooltip, TooltipContent, TooltipTrigger } from "talisman-ui"
-import { Balance, Balances, ChainId, EvmNetworkId } from "@extension/core"
+import { Balance, Balances } from "@extension/core"
import { FadeIn } from "@talisman/components/FadeIn"
import { SuspenseTracker } from "@talisman/components/SuspenseTracker"
import { api } from "@ui/api"
@@ -30,20 +30,17 @@ import { PortfolioAccount } from "./PortfolioAccount"
import { SendFundsButton } from "./SendFundsIconButton"
import { TokenContextMenu } from "./TokenContextMenu"
import { useAssetDetails } from "./useAssetDetails"
-import { DetailRow, useChainTokenBalances } from "./useChainTokenBalances"
+import { BalanceDetailRow, useTokenBalances } from "./useTokenBalances"
import { useUniswapV2BalancePair } from "./useUniswapV2BalancePair"
-type AssetRowProps = {
- chainId: ChainId | EvmNetworkId
- balances: Balances
-}
-
-const ChainTokenBalances = ({ chainId, balances }: AssetRowProps) => {
- const { chainOrNetwork, summary, symbol, tokenId, detailRows, status, networkType } =
- useChainTokenBalances({ chainId, balances })
+const TokenBalances: FC<{ tokenId: TokenId; balances: Balances }> = ({ tokenId, balances }) => {
+ const { chainOrNetwork, summary, token, detailRows, status, networkType } = useTokenBalances({
+ tokenId,
+ balances,
+ })
// wait for data to load
- if (!chainOrNetwork || !summary || !symbol || balances.count === 0) return null
+ if (!chainOrNetwork || !summary || !token || balances.count === 0) return null
const isUniswapV2LpToken = balances.sorted[0]?.source === "evm-uniswapv2"
@@ -65,7 +62,7 @@ const ChainTokenBalances = ({ chainId, balances }: AssetRowProps) => {
{chainOrNetwork.name}
}>
-
+