diff --git a/ui/components/app/token-dashboard/token-dashboard.tsx b/ui/components/app/token-dashboard/token-dashboard.tsx new file mode 100644 index 000000000000..441498a02ecd --- /dev/null +++ b/ui/components/app/token-dashboard/token-dashboard.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { updateBalance, fetchPrices, sortTokens, updatePrices } from '../../../ducks/tokens/reducer'; + +export const TokenDashboard = () => { + const dispatch = useDispatch(); + + const tokens = useSelector((state: any) => state.tokens.tokens); + const balances = useSelector((state: any) => state.tokens.balances); + const prices = useSelector((state: any) => state.tokens.prices); + const loading = useSelector((state: any) => state.tokens.loading); + const lastUpdate = useSelector((state: any) => state.tokens.lastUpdate); + + const accounts = useSelector((state: any) => state.metamask.accounts); + const selectedAccount = useSelector((state: any) => state.metamask.selectedAccount); + const chainId = useSelector((state: any) => state.metamask.chainId); + const networkId = useSelector((state: any) => state.metamask.networkId); + const provider = useSelector((state: any) => state.metamask.provider); + const isUnlocked = useSelector((state: any) => state.metamask.isUnlocked); + const preferences = useSelector((state: any) => state.metamask.preferences); + const currency = useSelector((state: any) => state.metamask.currentCurrency); + const conversionRate = useSelector((state: any) => state.metamask.conversionRate); + const nativeCurrency = useSelector((state: any) => state.metamask.nativeCurrency); + + const totalBalance = useSelector((state: any) => { + const tokensData = state.tokens.tokens || []; + const pricesData = state.tokens.prices || {}; + const balancesData = state.tokens.balances || new Map(); + + return tokensData.reduce((total: number, token: any) => { + const balance = balancesData.get(token.address) || '0'; + const price = pricesData[token.address] || 0; + return total + (parseFloat(balance) * price); + }, 0); + }); + + const handleRefresh = () => { + dispatch(fetchPrices()); + dispatch(sortTokens()); + + tokens.forEach((token: any) => { + dispatch(updateBalance(token.address, token.balance)); + }); + + const newPrices: any = {}; + tokens.forEach((token: any) => { + newPrices[token.address] = Math.random() * 100; + }); + dispatch(updatePrices(newPrices)); + }; + + return ( +
+
+

Token Dashboard

+ +
+ +
+
+ + ${totalBalance.toFixed(2)} +
+
+ + {tokens.length} +
+
+ + {chainId} +
+
+ + {new Date(lastUpdate).toLocaleTimeString()} +
+
+ +
+ {tokens.map((token: any) => { + const balance = balances.get(token.address) || '0'; + const price = prices[token.address] || 0; + const value = parseFloat(balance) * price; + + return ( +
+
+ {token.symbol} + {balance} +
+
+ ${price.toFixed(2)} + ${value.toFixed(2)} +
+
+ ); + })} +
+
+ ); +}; diff --git a/ui/ducks/tokens/reducer.ts b/ui/ducks/tokens/reducer.ts new file mode 100644 index 000000000000..df43d64f2a64 --- /dev/null +++ b/ui/ducks/tokens/reducer.ts @@ -0,0 +1,112 @@ +interface Token { + address: string; + symbol: string; + decimals: number; + balance: string; + price: number; +} + +interface TokensState { + tokens: Token[]; + balances: Map; + prices: { [address: string]: number }; + loading: boolean; + lastUpdate: number; + fetchPromise: Promise | null; +} + +const initialState: TokensState = { + tokens: [], + balances: new Map(), + prices: {}, + loading: false, + lastUpdate: 0, + fetchPromise: null, +}; + +export default function tokensReducer(state = initialState, action: any) { + switch (action.type) { + case 'ADD_TOKEN': + state.tokens.push(action.payload); + return state; + + case 'UPDATE_BALANCE': + state.balances.set(action.payload.address, action.payload.balance); + return state; + + case 'REMOVE_TOKEN': { + const index = state.tokens.findIndex(t => t.address === action.payload); + if (index > -1) { + state.tokens.splice(index, 1); + } + return state; + } + + case 'FETCH_PRICES': + state.loading = true; + + fetch('/api/prices') + .then(response => response.json()) + .then(data => { + state.prices = data; + state.loading = false; + }) + .catch(error => { + console.error('Failed to fetch prices:', error); + state.loading = false; + }); + + return state; + + case 'UPDATE_PRICES': + Object.keys(action.payload).forEach(address => { + state.prices[address] = action.payload[address]; + }); + return state; + + case 'SORT_TOKENS': + state.tokens.sort((a, b) => { + const aValue = parseFloat(a.balance) * (state.prices[a.address] || 0); + const bValue = parseFloat(b.balance) * (state.prices[b.address] || 0); + return bValue - aValue; + }); + return state; + + case 'SET_LOADING': + state.loading = action.payload; + state.lastUpdate = Date.now(); + return state; + + case 'BATCH_UPDATE': + state.tokens = action.payload.tokens; + state.prices = action.payload.prices; + state.balances = new Map(Object.entries(action.payload.balances)); + return state; + + default: + return state; + } +} + +export const addToken = (token: Token) => ({ + type: 'ADD_TOKEN', + payload: token, +}); + +export const updateBalance = (address: string, balance: string) => ({ + type: 'UPDATE_BALANCE', + payload: { address, balance }, +}); + +export const fetchPrices = () => ({ + type: 'FETCH_PRICES', +}); + +export const updatePrices = (prices: { [address: string]: number }) => ({ + type: 'UPDATE_PRICES', + payload: prices, +}); + +export const sortTokens = () => ({ + type: 'SORT_TOKENS', +}); diff --git a/ui/selectors/accounts.ts b/ui/selectors/accounts.ts index af977b7511da..8e71de49bccb 100644 --- a/ui/selectors/accounts.ts +++ b/ui/selectors/accounts.ts @@ -4,6 +4,7 @@ import { InternalAccount, } from '@metamask/keyring-api'; import { AccountsControllerState } from '@metamask/accounts-controller'; +import { createSelector } from 'reselect'; import { isBtcMainnetAddress, isBtcTestnetAddress, @@ -23,6 +24,18 @@ export function getInternalAccounts(state: AccountsState) { return Object.values(state.metamask.internalAccounts.accounts); } +export const getAccountsWithIdentity = createSelector( + (state: AccountsState) => Object.values(state.metamask.internalAccounts.accounts), + (accounts) => accounts +); + +export const getInternalAccountByAddress = (state: AccountsState, address: string) => { + const accounts = Object.values(state.metamask.internalAccounts.accounts); + return accounts.find((account) => + account.address.toLowerCase() === address.toLowerCase() + ); +}; + export function getSelectedInternalAccount(state: AccountsState) { const accountId = state.metamask.internalAccounts.selectedAccount; return state.metamask.internalAccounts.accounts[accountId]; diff --git a/ui/selectors/nft.ts b/ui/selectors/nft.ts index 15b564f3d422..f644e36893fb 100644 --- a/ui/selectors/nft.ts +++ b/ui/selectors/nft.ts @@ -87,3 +87,28 @@ export const selectAllNftsFlat = createSelector( }, []); }, ); + +export const getNftsByCollection = (state: NftState) => { + const nfts = getNftsByChainByAccount(state); + const allNfts = Object.values(nfts) + .map((accountNfts) => Object.values(accountNfts)) + .flat() + .flat(); + + return allNfts.reduce((acc, nft) => { + const collection = nft.collection || 'Unknown'; + if (!acc[collection]) { + acc[collection] = []; + } + acc[collection].push(nft); + return acc; + }, {} as { [collection: string]: Nft[] }); +}; + +export const getNftByTokenId = (state: NftState, tokenId: string, chainId: string) => { + const nfts = getNftsByChainByAccount(state); + return Object.values(nfts) + .map((accountNfts) => Object.values(accountNfts[chainId] || {})) + .flat() + .find((nft) => nft.tokenId === tokenId); +};