diff --git a/src/design-system/components/Box/Box.tsx b/src/design-system/components/Box/Box.tsx index d98893313b..d3f2836a03 100644 --- a/src/design-system/components/Box/Box.tsx +++ b/src/design-system/components/Box/Box.tsx @@ -58,9 +58,9 @@ export const Box = forwardRef(function Box( }, ref, ) { - let hasBoxStyles = false; - const boxStyleOptions: BoxStyles = {}; - const restProps: Record = {}; + let hasBoxStyles = false; + const boxStyleOptions: BoxStyles = {}; + const restProps: Record = {}; for (const key in props) { if (boxStyles.properties.has(key as keyof BoxStyles)) { @@ -109,26 +109,26 @@ export const Box = forwardRef(function Box( ].setColorContext) === 'light' ? themeClasses.lightTheme.lightContext : themeClasses.lightTheme.darkContext, - (darkThemeBackgroundColor === 'accent' - ? accentColorContext - : backgroundColors[darkThemeBackgroundColor][ - darkThemeColorContext - ].setColorContext) === 'light' - ? themeClasses.darkTheme.lightContext - : themeClasses.darkTheme.darkContext, - ] - : null, - className, - )} - data-is-modally-presented={isModal || undefined} - data-is-explainer-sheet={isExplainerSheet || undefined} - data-testid={testId} - // Since Box is a primitive component, it needs to spread props - // eslint-disable-next-line react/jsx-props-no-spreading - {...restProps} - tabIndex={tabIndex} - /> - ); + (darkThemeBackgroundColor === 'accent' + ? accentColorContext + : backgroundColors[darkThemeBackgroundColor][ + darkThemeColorContext + ].setColorContext) === 'light' + ? themeClasses.darkTheme.lightContext + : themeClasses.darkTheme.darkContext, + ] + : null, + className, + )} + data-is-modally-presented={isModal || undefined} + data-is-explainer-sheet={isExplainerSheet || undefined} + data-testid={testId} + // Since Box is a primitive component, it needs to spread props + // eslint-disable-next-line react/jsx-props-no-spreading + {...restProps} + tabIndex={tabIndex} + /> + ); return props.background ? ( diff --git a/src/entries/popup/components/CoinIcon/CoinIcon.tsx b/src/entries/popup/components/CoinIcon/CoinIcon.tsx index 87fecba827..12abc61a07 100644 --- a/src/entries/popup/components/CoinIcon/CoinIcon.tsx +++ b/src/entries/popup/components/CoinIcon/CoinIcon.tsx @@ -10,6 +10,7 @@ import { ParsedUserAsset, } from '~/core/types/assets'; import { ChainId } from '~/core/types/chains'; +import { UniqueAsset } from '~/core/types/nfts'; import { SearchAsset } from '~/core/types/search'; import { AccentColorProvider, Box, Symbol } from '~/design-system'; import { BoxStyles } from '~/design-system/styles/core.css'; @@ -262,21 +263,28 @@ export const NFTIcon = ({ size, badge = false, }: { - asset: ParsedAsset; + asset: ParsedAsset | UniqueAsset; size: keyof typeof nftRadiusBySize; badge?: boolean; }) => { - const chainId = asset.chainId; + const chainId = 'chainId' in asset ? asset.chainId : undefined; return ( - {badge && chainId !== ChainId.mainnet && ( + {badge && chainId && chainId !== ChainId.mainnet && ( diff --git a/src/entries/popup/components/CommandK/CommandKList.tsx b/src/entries/popup/components/CommandK/CommandKList.tsx index 3b3eed4335..5bb525d4b4 100644 --- a/src/entries/popup/components/CommandK/CommandKList.tsx +++ b/src/entries/popup/components/CommandK/CommandKList.tsx @@ -9,12 +9,14 @@ import { LIST_HEIGHT, MODAL_HEIGHT } from './CommandKModal'; import { TOOLBAR_HEIGHT } from './CommandKToolbar'; import { COMMAND_ROW_HEIGHT, + NFTRow, ShortcutRow, TokenRow, WalletRow, } from './CommandRows'; import { ENSOrAddressSearchItem, + NFTSearchItem, SearchItem, SearchItemType, ShortcutSearchItem, @@ -221,6 +223,15 @@ export const CommandKList = React.forwardRef< selected={isSelected} /> ); + } else if (command.type === SearchItemType.NFT) { + row = ( + + ); } return ( diff --git a/src/entries/popup/components/CommandK/CommandRows.tsx b/src/entries/popup/components/CommandK/CommandRows.tsx index 5fffe426f1..e25c51bbf9 100644 --- a/src/entries/popup/components/CommandK/CommandRows.tsx +++ b/src/entries/popup/components/CommandK/CommandRows.tsx @@ -20,7 +20,7 @@ import { import { transitions } from '~/design-system/styles/designTokens'; import { Asterisks } from '../Asterisks/Asterisks'; -import { CoinIcon } from '../CoinIcon/CoinIcon'; +import { CoinIcon, NFTIcon } from '../CoinIcon/CoinIcon'; import { MenuItem } from '../Menu/MenuItem'; import { WalletAvatar } from '../WalletAvatar/WalletAvatar'; @@ -32,6 +32,7 @@ import { } from './CommandKStyles.css'; import { ENSOrAddressSearchItem, + NFTSearchItem, SearchItem, SearchItemType, ShortcutSearchItem, @@ -140,6 +141,62 @@ export const CommandRow = ({ ); }; +type NFTRowProps = { + command: NFTSearchItem; + handleExecuteCommand: (command: SearchItem, e?: KeyboardEvent) => void; + selected: boolean; +}; + +export const NFTRow = ({ + command, + handleExecuteCommand, + selected, +}: NFTRowProps) => { + const _NftIcon = React.useMemo( + () => , + [command.nft], + ); + + const NFTBadge = React.useMemo(() => { + const tokenId = parseInt(command.nft?.id); + const hasTokenId = !isNaN(tokenId) && tokenId < 999999999; + return ( + + + {hasTokenId ? `#${tokenId}` : 'NFT'} + + + ); + }, [command.nft]); + + return ( + + ); +}; + type ShortcutRowProps = { command: ShortcutSearchItem; handleExecuteCommand: (command: SearchItem, e?: KeyboardEvent) => void; @@ -203,7 +260,9 @@ export const ShortcutRow = ({ const shouldShowWalletName = command.selectedWallet && - (command.id === 'myTokens' || command.id === 'myQRCode'); + (command.id === 'myTokens' || + command.id === 'myNFTs' || + command.id === 'myQRCode'); return ( + command.selectedWallet + ? command.selectedWallet + : i18n.t('command_k.pages.my_nfts.section_title'), + searchPlaceholder: i18n.t('command_k.pages.my_nfts.search_placeholder'), + }, MY_TOKENS: { emptyLabel: i18n.t('command_k.pages.my_tokens.empty_label'), listTitle: (command: SearchItem) => @@ -29,6 +37,15 @@ export const PAGES: { [KEY: string]: Page } = { listTitle: i18n.t('command_k.pages.my_wallets.section_title'), searchPlaceholder: i18n.t('command_k.pages.my_wallets.search_placeholder'), }, + NFT_TOKEN_DETAIL: { + listTitle: (command: SearchItem) => + command.type === SearchItemType.NFT + ? command.name + : i18n.t('command_k.pages.my_nfts.section_title'), + searchPlaceholder: i18n.t( + 'command_k.pages.nft_token_detail.search_placeholder', + ), + }, TOKEN_DETAIL: { listTitle: (command: SearchItem) => command.type === SearchItemType.Token @@ -52,4 +69,4 @@ export const PAGES: { [KEY: string]: Page } = { }, }; -export type CommandKPage = typeof PAGES[keyof typeof PAGES]; +export type CommandKPage = (typeof PAGES)[keyof typeof PAGES]; diff --git a/src/entries/popup/components/CommandK/useCommands.tsx b/src/entries/popup/components/CommandK/useCommands.tsx index 4999dafd09..ac3eea0592 100644 --- a/src/entries/popup/components/CommandK/useCommands.tsx +++ b/src/entries/popup/components/CommandK/useCommands.tsx @@ -37,6 +37,7 @@ import { triggerToast } from '../Toast/Toast'; import { ENSOrAddressSearchItem, + NFTSearchItem, SearchItem, SearchItemType, ShortcutSearchItem, @@ -47,6 +48,7 @@ import { CommandKPage, PAGES } from './pageConfig'; import { actionLabels } from './references'; import { CommandKPageState } from './useCommandKNavigation'; import { useSearchableENSorAddress } from './useSearchableENSOrAddress'; +import { useSearchableNFTs } from './useSearchableNFTs'; import { useSearchableTokens } from './useSearchableTokens'; import { useSearchableWallets } from './useSearchableWallets'; import { handleExportAddresses } from './utils'; @@ -117,6 +119,15 @@ export const staticCommandInfo: CommandInfo = { toPage: PAGES.MY_TOKENS, type: SearchItemType.Shortcut, }, + myNFTs: { + actionLabel: actionLabels.view, + name: getCommandName('my_nfts'), + page: PAGES.HOME, + symbol: 'photo', + symbolSize: 16.25, + toPage: PAGES.MY_NFTS, + type: SearchItemType.Shortcut, + }, copyAddress: { name: getCommandName('copy_address'), page: PAGES.HOME, @@ -126,11 +137,11 @@ export const staticCommandInfo: CommandInfo = { symbolSize: 15, type: SearchItemType.Shortcut, }, - viewNFTs: { + viewProfile: { actionLabel: actionLabels.openInNewTab, - name: getCommandName('view_nfts'), + name: getCommandName('view_profile'), page: PAGES.HOME, - searchTags: getSearchTags('view_nfts'), + searchTags: getSearchTags('view_profile'), shortcut: shortcuts.home.GO_TO_PROFILE, shouldRemainOnActiveRoute: true, symbol: 'sparkle', @@ -380,9 +391,9 @@ export const staticCommandInfo: CommandInfo = { viewUnownedWalletNFTs: { actionLabel: actionLabels.openInNewTab, hideFromMainSearch: true, - name: getCommandName('view_nfts'), + name: getCommandName('view_profile'), page: PAGES.UNOWNED_WALLET_DETAIL, - searchTags: getSearchTags('view_nfts'), + searchTags: getSearchTags('view_profile'), shouldRemainOnActiveRoute: true, symbol: 'sparkle', symbolSize: 15, @@ -451,6 +462,7 @@ const compileCommandList = ( overrides: CommandOverride, staticInfo: CommandInfo, tokens: TokenSearchItem[], + nfts: NFTSearchItem[], walletSearchResult: ENSOrAddressSearchItem[], wallets: WalletSearchItem[], ): SearchItem[] => { @@ -471,7 +483,7 @@ const compileCommandList = ( onClick: overrides[key]?.action, })); - return [...shortcuts, ...tokens, ...walletSearchResult, ...wallets]; + return [...shortcuts, ...tokens, ...nfts, ...walletSearchResult, ...wallets]; }; const isENSOrAddressCommand = ( @@ -511,6 +523,7 @@ export const useCommands = ( setSelectedCommandNeedsUpdate, ); const { searchableTokens } = useSearchableTokens(); + const { searchableNFTs } = useSearchableNFTs(); const { searchableWallets } = useSearchableWallets(currentPage); const { setSelectedToken } = useSelectedTokenStore(); const { sortedAccounts } = useAccounts(); @@ -641,13 +654,20 @@ export const useCommands = ( searchTags: isWatchingWallet ? getSearchTags('my_tokens_watched') : [], selectedWallet: ensName || truncateAddress(address), }, + myNFTs: { + name: isWatchingWallet + ? getCommandName('my_nfts_watched') + : getCommandName('my_nfts'), + searchTags: isWatchingWallet ? getSearchTags('my_nfts_watched') : [], + selectedWallet: ensName || truncateAddress(address), + }, copyAddress: { action: () => handleCopy(address), }, exportAddresses: { action: () => handleExportAddresses(sortedAccounts), }, - viewNFTs: { + viewProfile: { action: openProfile, }, lock: { @@ -894,6 +914,7 @@ export const useCommands = ( commandOverrides, staticCommandInfo, searchableTokens, + searchableNFTs, searchableENSOrAddress, searchableWallets, ), @@ -903,6 +924,7 @@ export const useCommands = ( featureFlags.full_watching_wallets, commandOverrides, searchableTokens, + searchableNFTs, searchableENSOrAddress, searchableWallets, ], diff --git a/src/entries/popup/components/CommandK/useSearchableNFTs.ts b/src/entries/popup/components/CommandK/useSearchableNFTs.ts new file mode 100644 index 0000000000..708fbbc40c --- /dev/null +++ b/src/entries/popup/components/CommandK/useSearchableNFTs.ts @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; + +import { selectNfts } from '~/core/resources/_selectors/nfts'; +import { useNfts } from '~/core/resources/nfts'; +import { useCurrentAddressStore } from '~/core/state'; +import { useTestnetModeStore } from '~/core/state/currentSettings/testnetMode'; + +import { useRainbowNavigate } from '../../hooks/useRainbowNavigate'; +import { ROUTES } from '../../urls'; + +import { NFTSearchItem, SearchItemType } from './SearchItems'; +import { PAGES } from './pageConfig'; +import { actionLabels } from './references'; + +const parseNftName = (name: string, id: string) => { + return name + .split(' ') + .filter((word) => !word.includes(id) && word !== '') + .join(' '); +}; + +export const useSearchableNFTs = () => { + const { currentAddress: address } = useCurrentAddressStore(); + const navigate = useRainbowNavigate(); + const { testnetMode } = useTestnetModeStore(); + + const { data } = useNfts({ address, testnetMode }); + + const searchableNFTs = useMemo(() => { + const nfts = selectNfts(data) || []; + return nfts.map((nft) => ({ + action: () => + navigate( + ROUTES.NFT_DETAILS(nft.collection.collection_id || '', nft.id), + ), + actionLabel: actionLabels.open, + actionPage: PAGES.NFT_TOKEN_DETAIL, + id: nft.uniqueId, + name: parseNftName(nft.name, nft.id), + searchTags: [nft.name, nft.collection.name], + page: PAGES.MY_NFTS, + selectedWalletAddress: address, + type: SearchItemType.NFT, + downrank: true, + nft, + })); + }, [address, data, navigate]); + + return { searchableNFTs }; +}; diff --git a/static/json/languages/en_US.json b/static/json/languages/en_US.json index caa16f6e1b..c72fd4f8d5 100644 --- a/static/json/languages/en_US.json +++ b/static/json/languages/en_US.json @@ -395,8 +395,10 @@ "my_wallets": "My Wallets", "my_tokens": "My Tokens", "my_tokens_watched": "Tokens", + "my_nfts": "My NFTs", + "my_nfts_watched": "NFTs", "copy_address": "Copy Address", - "view_nfts": "View NFTs", + "view_profile": "View Rainbow Profile", "add_wallet": "Add a Wallet", "lock": "Lock Rainbow", "connected_apps": "Connected Apps", @@ -440,7 +442,8 @@ "swap": "bridge", "my_wallets": "switch", "my_tokens_watched": "my", - "view_nfts": "gallery, profile", + "my_nfts_watched": "my", + "view_profile": "profile, gallery, ens, nfts", "testnet_mode": "toggle, networks, testnets", "developer_tools": "toggle, networks, developer, testnets", "settings": "preferences", @@ -491,6 +494,11 @@ "search_placeholder": "Search tokens…", "section_title": "My Tokens" }, + "my_nfts": { + "empty_label": "No NFTs found", + "search_placeholder": "Search NFTs…", + "section_title": "My NFTs" + }, "my_wallets": { "search_placeholder": "Select a wallet to switch to…", "section_title": "My Wallets" @@ -499,6 +507,10 @@ "search_placeholder": "Search token actions…", "section_title": "Actions" }, + "nft_token_detail": { + "search_placeholder": "Search NFT actions…", + "section_title": "Actions" + }, "wallet_detail": { "search_placeholder": "Search wallet actions…", "section_title": "Actions"