diff --git a/Sources/BraveWallet/Crypto/NFT/NFTDetailView.swift b/Sources/BraveWallet/Crypto/NFT/NFTDetailView.swift index 1469b036bd6..7a577ee2395 100644 --- a/Sources/BraveWallet/Crypto/NFT/NFTDetailView.swift +++ b/Sources/BraveWallet/Crypto/NFT/NFTDetailView.swift @@ -51,6 +51,26 @@ struct NFTDetailView: View { .frame(maxWidth: .infinity, minHeight: 300) } else { nftImage + .overlay(alignment: .topLeading) { + if nftDetailStore.nft.isSpam { + HStack(spacing: 4) { + Text(Strings.Wallet.nftSpam) + .padding(.vertical, 4) + .padding(.leading, 6) + .foregroundColor(Color(.braveErrorLabel)) + Image(braveSystemName: "leo.warning.triangle-outline") + .padding(.vertical, 4) + .padding(.trailing, 6) + .foregroundColor(Color(.braveErrorBorder)) + } + .font(.system(size: 13).weight(.semibold)) + .background( + Color(uiColor: WalletV2Design.spamNFTLabelBackground) + .cornerRadius(4) + ) + .padding(12) + } + } } VStack(alignment: .leading, spacing: 8) { Text(nftDetailStore.nft.nftTokenTitle) diff --git a/Sources/BraveWallet/Crypto/NFT/NFTView.swift b/Sources/BraveWallet/Crypto/NFT/NFTView.swift index a0c989e3fd2..474809c1ff1 100644 --- a/Sources/BraveWallet/Crypto/NFT/NFTView.swift +++ b/Sources/BraveWallet/Crypto/NFT/NFTView.swift @@ -20,6 +20,7 @@ struct NFTView: View { @State private var isShowingNFTDiscoveryAlert: Bool = false @State private var isShowingAddCustomNFT: Bool = false @State private var isNFTDiscoveryEnabled: Bool = false + @State private var nftToBeRemoved: NFTAssetViewModel? @Environment(\.buySendSwapDestination) private var buySendSwapDestination: Binding @@ -205,17 +206,47 @@ struct NFTView: View { .multilineTextAlignment(.leading) } } + .overlay(alignment: .topLeading) { + if nft.token.isSpam { + HStack(spacing: 4) { + Text(Strings.Wallet.nftSpam) + .padding(.vertical, 4) + .padding(.leading, 6) + .foregroundColor(Color(.braveErrorLabel)) + Image(braveSystemName: "leo.warning.triangle-outline") + .padding(.vertical, 4) + .padding(.trailing, 6) + .foregroundColor(Color(.braveErrorBorder)) + } + .font(.system(size: 13).weight(.semibold)) + .background( + Color(uiColor: WalletV2Design.spamNFTLabelBackground) + .cornerRadius(4) + ) + .padding(12) + } + } } .contextMenu { Button(action: { - nftStore.updateNFTStatus(nft.token, visible: isHiddenNFT(nft.token), isSpam: false) + if nft.token.visible { // a collected visible NFT, mark as hidden + nftStore.updateNFTStatus(nft.token, visible: false, isSpam: false, isDeletedByUser: false) + } else { // either a hidden NFT or a junk NFT, mark as visible + nftStore.updateNFTStatus(nft.token, visible: true, isSpam: false, isDeletedByUser: false) + } }) { - Label(isHiddenNFT(nft.token) ? Strings.Wallet.nftUnhide : Strings.recentSearchHide, braveSystemImage: isHiddenNFT(nft.token) ? "leo.eye.on" : "leo.eye.off") + if nft.token.visible { // a collected visible NFT + Label(Strings.recentSearchHide, braveSystemImage: "leo.eye.off") + } else if nft.token.isSpam { // a spam NFT + Label(Strings.Wallet.nftUnspam, braveSystemImage: "leo.disable.outline") + } else { // a hidden but not spam NFT + Label(Strings.Wallet.nftUnhide, braveSystemImage: "leo.eye.on") + } } Button(action: { - nftStore.updateNFTStatus(nft.token, visible: isSpamNFT(nft.token), isSpam: !isSpamNFT(nft.token)) + nftToBeRemoved = nft }) { - Label(isSpamNFT(nft.token) ? Strings.Wallet.nftUnspam : Strings.Wallet.nftMoveToSpam, braveSystemImage: "leo.disable.outline") + Label(Strings.Wallet.nftRemoveFromWallet, braveSystemImage: "leo.trash") } } } @@ -301,6 +332,38 @@ struct NFTView: View { } ) ) + .background( + WalletPromptView( + isPresented: Binding( + get: { nftToBeRemoved != nil }, + set: { if !$0 { nftToBeRemoved = nil } } + ), + primaryButton: .init( + title: Strings.Wallet.manageSiteConnectionsConfirmAlertRemove, + action: { _ in + guard let nft = nftToBeRemoved else { return } + nftStore.updateNFTStatus(nft.token, visible: false, isSpam: nft.token.isSpam, isDeletedByUser: true) + nftToBeRemoved = nil + } + ), + secondaryButton: .init( + title: Strings.CancelString, + action: { _ in + nftToBeRemoved = nil + } + ), + showCloseButton: false, + content: { + VStack(spacing: 16) { + Text(Strings.Wallet.nftRemoveFromWalletAlertTitle) + .font(.headline) + .foregroundColor(Color(.bravePrimary)) + Text(Strings.Wallet.nftRemoveFromWalletAlertDescription) + .font(.footnote) + .foregroundStyle(Color(.secondaryBraveLabel)) + } + }) + ) .sheet(isPresented: $isShowingAddCustomNFT) { AddCustomAssetView( networkStore: networkStore, @@ -321,22 +384,6 @@ struct NFTView: View { } } } - - private func isSpamNFT(_ nft: BraveWallet.BlockchainToken) -> Bool { - if nftStore.displayType == .spam { - return true - } else { - return nft.isSpam - } - } - - private func isHiddenNFT(_ nft: BraveWallet.BlockchainToken) -> Bool { - if nftStore.displayType == .spam { - return false - } else { - return !nft.visible - } - } } #if DEBUG diff --git a/Sources/BraveWallet/Crypto/Stores/AccountActivityStore.swift b/Sources/BraveWallet/Crypto/Stores/AccountActivityStore.swift index e0c1f045b9e..357e403dc48 100644 --- a/Sources/BraveWallet/Crypto/Stores/AccountActivityStore.swift +++ b/Sources/BraveWallet/Crypto/Stores/AccountActivityStore.swift @@ -141,7 +141,7 @@ class AccountActivityStore: ObservableObject, WalletObserverStore { let tokens: [BraveWallet.BlockchainToken] let sortOrder: Int } - let allUserAssets = assetManager.getAllUserAssetsInNetworkAssets(networks: networksForAccount, includingSpam: true) + let allUserAssets = assetManager.getAllUserAssetsInNetworkAssets(networks: networksForAccount, includingUserDeleted: true) let allTokens = await blockchainRegistry.allTokens(in: networksForAccountCoin).flatMap(\.tokens) var updatedUserAssets: [AssetViewModel] = [] var updatedUserNFTs: [NFTAssetViewModel] = [] diff --git a/Sources/BraveWallet/Crypto/Stores/AssetDetailStore.swift b/Sources/BraveWallet/Crypto/Stores/AssetDetailStore.swift index 1dcd31e58d4..b043d3de902 100644 --- a/Sources/BraveWallet/Crypto/Stores/AssetDetailStore.swift +++ b/Sources/BraveWallet/Crypto/Stores/AssetDetailStore.swift @@ -317,7 +317,7 @@ class AssetDetailStore: ObservableObject, WalletObserverStore { ) async -> [TransactionSummary] { guard case let .blockchainToken(token) = assetDetailType else { return [] } - let userAssets = assetManager.getAllUserAssetsInNetworkAssets(networks: [network], includingSpam: true).flatMap { $0.tokens } + let userAssets = assetManager.getAllUserAssetsInNetworkAssets(networks: [network], includingUserDeleted: true).flatMap { $0.tokens } let allTokens = await blockchainRegistry.allTokens(network.chainId, coin: network.coin) let allTransactions = await withTaskGroup(of: [BraveWallet.TransactionInfo].self) { @MainActor group -> [BraveWallet.TransactionInfo] in for account in keyring.accountInfos { diff --git a/Sources/BraveWallet/Crypto/Stores/NFTStore.swift b/Sources/BraveWallet/Crypto/Stores/NFTStore.swift index 3ed0e444408..59dcbbf81a6 100644 --- a/Sources/BraveWallet/Crypto/Stores/NFTStore.swift +++ b/Sources/BraveWallet/Crypto/Stores/NFTStore.swift @@ -30,9 +30,7 @@ public class NFTStore: ObservableObject, WalletObserverStore { case .visible: return userNFTs.filter(\.token.visible) case .hidden: - return userNFTs.filter { !$0.token.visible && !$0.token.isSpam } - case .spam: - return userNFTs.filter(\.token.isSpam) + return userNFTs.filter { !$0.token.visible } } } /// All User Accounts @@ -75,7 +73,6 @@ public class NFTStore: ObservableObject, WalletObserverStore { enum NFTDisplayType: Int, CaseIterable, Identifiable { case visible case hidden - case spam var id: Int { rawValue @@ -84,11 +81,9 @@ public class NFTStore: ObservableObject, WalletObserverStore { var dropdownTitle: String { switch self { case .visible: - return Strings.Wallet.nftsTitle + return Strings.Wallet.nftCollected case .hidden: return Strings.Wallet.nftHidden - case .spam: - return Strings.Wallet.nftSpam } } @@ -98,8 +93,6 @@ public class NFTStore: ObservableObject, WalletObserverStore { return Strings.Wallet.nftPageEmptyTitle case .hidden: return Strings.Wallet.nftInvisiblePageEmptyTitle - case .spam: - return Strings.Wallet.nftSpamPageEmptyTitle } } @@ -107,7 +100,7 @@ public class NFTStore: ObservableObject, WalletObserverStore { switch self { case .visible: return Strings.Wallet.nftPageEmptyDescription - case .hidden, .spam: + case .hidden: return nil } } @@ -344,17 +337,21 @@ public class NFTStore: ObservableObject, WalletObserverStore { selectedAccounts: [BraveWallet.AccountInfo], simpleHashSpamNFTs: [NetworkAssets] ) -> [NetworkAssets] { + // all user marked deleted NFTs + let allUserMarkedDeletedNFTs = assetManager.getAllUserDeletedNFTs() // all spam NFTs marked by user let allUserMarkedSpamNFTs = assetManager.getAllUserNFTs(networks: selectedNetworks, isSpam: true) // filter out any spam NFTs from `simpleHashSpamNFTs` that are marked - // not-spam by user + // not-spam or deleted by user var updatedSimpleHashSpamNFTs: [NetworkAssets] = [] for simpleHashSpamNFTsOnNetwork in simpleHashSpamNFTs { let userMarkedNotSpamTokensOnNetwork = assetManager.getAllUserNFTs(networks: [simpleHashSpamNFTsOnNetwork.network], isSpam: false).flatMap(\.tokens) let filteredSimpleHashSpamTokens = simpleHashSpamNFTsOnNetwork.tokens.filter { simpleHashSpamToken in - !userMarkedNotSpamTokensOnNetwork.contains { token in + return !userMarkedNotSpamTokensOnNetwork.contains { token in token.id == simpleHashSpamToken.id - } + } && !allUserMarkedDeletedNFTs.contains(where: { deletedNFT in + deletedNFT.contractAddress == simpleHashSpamToken.contractAddress && deletedNFT.chainId == simpleHashSpamToken.chainId && deletedNFT.tokenId == simpleHashSpamToken.tokenId + }) } updatedSimpleHashSpamNFTs.append(NetworkAssets(network: simpleHashSpamNFTsOnNetwork.network, tokens: filteredSimpleHashSpamTokens, sortOrder: simpleHashSpamNFTsOnNetwork.sortOrder)) } @@ -387,8 +384,18 @@ public class NFTStore: ObservableObject, WalletObserverStore { walletService.setNftDiscoveryEnabled(true) } - func updateNFTStatus(_ token: BraveWallet.BlockchainToken, visible: Bool, isSpam: Bool) { - assetManager.updateUserAsset(for: token, visible: visible, isSpam: isSpam) { [weak self] in + func updateNFTStatus( + _ token: BraveWallet.BlockchainToken, + visible: Bool, + isSpam: Bool, + isDeletedByUser: Bool + ) { + assetManager.updateUserAsset( + for: token, + visible: visible, + isSpam: isSpam, + isDeletedByUser: isDeletedByUser + ) { [weak self] in guard let self else { return } let selectedAccounts = self.filters.accounts.filter(\.isSelected).map(\.model) let selectedNetworks = self.filters.networks.filter(\.isSelected).map(\.model) diff --git a/Sources/BraveWallet/Crypto/Stores/TransactionConfirmationStore.swift b/Sources/BraveWallet/Crypto/Stores/TransactionConfirmationStore.swift index af314b6a65b..00035fff68c 100644 --- a/Sources/BraveWallet/Crypto/Stores/TransactionConfirmationStore.swift +++ b/Sources/BraveWallet/Crypto/Stores/TransactionConfirmationStore.swift @@ -277,7 +277,7 @@ public class TransactionConfirmationStore: ObservableObject, WalletObserverStore let transactionNetworks: [BraveWallet.NetworkInfo] = Set(allTxs.map(\.chainId)) .compactMap { chainId in allNetworks.first(where: { $0.chainId == chainId }) } for network in transactionNetworks { - let userAssets = assetManager.getAllUserAssetsInNetworkAssets(networks: [network], includingSpam: true).flatMap { $0.tokens } + let userAssets = assetManager.getAllUserAssetsInNetworkAssets(networks: [network], includingUserDeleted: true).flatMap { $0.tokens } await fetchAssetRatios(for: userAssets) } await fetchUnknownTokens(for: unapprovedTxs) @@ -307,7 +307,7 @@ public class TransactionConfirmationStore: ObservableObject, WalletObserverStore return } let allTokens = await blockchainRegistry.allTokens(network.chainId, coin: coin) + tokenInfoCache.map(\.value) - let userAssets = assetManager.getAllUserAssetsInNetworkAssets(networks: [network], includingSpam: true).flatMap { $0.tokens } + let userAssets = assetManager.getAllUserAssetsInNetworkAssets(networks: [network], includingUserDeleted: true).flatMap { $0.tokens } let solEstimatedTxFee: UInt64? = solEstimatedTxFeeCache[transaction.id] if transaction.isEIP1559Transaction { @@ -431,7 +431,7 @@ public class TransactionConfirmationStore: ObservableObject, WalletObserverStore guard let network = allNetworks.first(where: { $0.chainId == BraveWallet.MainnetChainId }) else { return } - let userAssets = assetManager.getAllUserAssetsInNetworkAssets(networks: [network], includingSpam: true).flatMap { $0.tokens } + let userAssets = assetManager.getAllUserAssetsInNetworkAssets(networks: [network], includingUserDeleted: true).flatMap { $0.tokens } let allTokens = await blockchainRegistry.allTokens(network.chainId, coin: network.coin) let unknownTokenContractAddresses = mainnetTransactions.flatMap(\.tokenContractAddresses) .filter { contractAddress in diff --git a/Sources/BraveWallet/Crypto/Stores/TransactionDetailsStore.swift b/Sources/BraveWallet/Crypto/Stores/TransactionDetailsStore.swift index 08ff67f8eb0..35836ecd6a9 100644 --- a/Sources/BraveWallet/Crypto/Stores/TransactionDetailsStore.swift +++ b/Sources/BraveWallet/Crypto/Stores/TransactionDetailsStore.swift @@ -80,7 +80,7 @@ class TransactionDetailsStore: ObservableObject, WalletObserverStore { let keringId = BraveWallet.KeyringId.keyringId(for: coin, on: transaction.chainId) let keyring = await keyringService.keyringInfo(keringId) var allTokens: [BraveWallet.BlockchainToken] = await blockchainRegistry.allTokens(network.chainId, coin: network.coin) + tokenInfoCache.map(\.value) - let userAssets: [BraveWallet.BlockchainToken] = assetManager.getAllUserAssetsInNetworkAssets(networks: [network], includingSpam: true).flatMap { $0.tokens } + let userAssets: [BraveWallet.BlockchainToken] = assetManager.getAllUserAssetsInNetworkAssets(networks: [network], includingUserDeleted: true).flatMap { $0.tokens } let unknownTokenContractAddresses = transaction.tokenContractAddresses .filter { contractAddress in !userAssets.contains(where: { $0.contractAddress(in: network).caseInsensitiveCompare(contractAddress) == .orderedSame }) diff --git a/Sources/BraveWallet/Crypto/Stores/TransactionsActivityStore.swift b/Sources/BraveWallet/Crypto/Stores/TransactionsActivityStore.swift index 62b4a7097ed..2230020bfe0 100644 --- a/Sources/BraveWallet/Crypto/Stores/TransactionsActivityStore.swift +++ b/Sources/BraveWallet/Crypto/Stores/TransactionsActivityStore.swift @@ -138,7 +138,7 @@ class TransactionsActivityStore: ObservableObject, WalletObserverStore { let allTransactions = await txService.allTransactions( networksForCoin: networksForCoin, for: allKeyrings ).filter { $0.txStatus != .rejected } - let userAssets = assetManager.getAllUserAssetsInNetworkAssets(networks: allNetworksAllCoins, includingSpam: true).flatMap(\.tokens) + let userAssets = assetManager.getAllUserAssetsInNetworkAssets(networks: allNetworksAllCoins, includingUserDeleted: true).flatMap(\.tokens) let allTokens = await blockchainRegistry.allTokens( in: allNetworksAllCoins ).flatMap(\.tokens) diff --git a/Sources/BraveWallet/Crypto/Stores/UserAssetsStore.swift b/Sources/BraveWallet/Crypto/Stores/UserAssetsStore.swift index e301cb95faa..b0345dc4769 100644 --- a/Sources/BraveWallet/Crypto/Stores/UserAssetsStore.swift +++ b/Sources/BraveWallet/Crypto/Stores/UserAssetsStore.swift @@ -13,7 +13,7 @@ public class AssetStore: ObservableObject, Equatable, WalletObserverStore { @Published var token: BraveWallet.BlockchainToken @Published var isVisible: Bool { didSet { - assetManager.updateUserAsset(for: token, visible: isVisible, isSpam: false, completion: nil) + assetManager.updateUserAsset(for: token, visible: isVisible, isSpam: false, isDeletedByUser: false, completion: nil) } } var network: BraveWallet.NetworkInfo @@ -137,7 +137,7 @@ public class UserAssetsStore: ObservableObject, WalletObserverStore { } } let networks: [BraveWallet.NetworkInfo] = self.networkFilters.filter(\.isSelected).map(\.model) - let allUserAssets = assetManager.getAllUserAssetsInNetworkAssets(networks: networks, includingSpam: true) + let allUserAssets = assetManager.getAllUserAssetsInNetworkAssets(networks: networks, includingUserDeleted: false) var allTokens = await self.blockchainRegistry.allTokens(in: networks) // Filter `allTokens` to remove any tokens existing in `allUserAssets`. This is possible for ERC721 tokens in the registry without a `tokenId`, which requires the user to add as a custom token let allUserTokens = allUserAssets.flatMap(\.tokens) @@ -183,7 +183,7 @@ public class UserAssetsStore: ObservableObject, WalletObserverStore { _ asset: BraveWallet.BlockchainToken, completion: @escaping (_ success: Bool) -> Void ) { - if assetManager.getUserAsset(asset) != nil { + if let existedAsset = assetManager.getUserAsset(asset), !existedAsset.isDeletedByUser { completion(false) } else { assetManager.addUserAsset(asset) { [weak self] in @@ -233,7 +233,7 @@ public class UserAssetsStore: ObservableObject, WalletObserverStore { @MainActor func allAssets() async -> [AssetViewModel] { let allNetworks = await rpcService.allNetworksForSupportedCoins() - let allUserAssets = assetManager.getAllUserAssetsInNetworkAssets(networks: allNetworks, includingSpam: true) + let allUserAssets = assetManager.getAllUserAssetsInNetworkAssets(networks: allNetworks, includingUserDeleted: false) // Filter `allTokens` to remove any tokens existing in `allUserAssets`. This is possible for ERC721 tokens in the registry without a `tokenId`, which requires the user to add as a custom token let allUserTokens = allUserAssets.flatMap(\.tokens) let allBlockchainTokens = await blockchainRegistry.allTokens(in: allNetworks) @@ -262,7 +262,7 @@ public class UserAssetsStore: ObservableObject, WalletObserverStore { @MainActor func allNFTMetadata() async -> [String: NFTMetadata] { let allNetworks = await rpcService.allNetworksForSupportedCoins() - let allUserAssets = assetManager.getAllUserAssetsInNetworkAssets(networks: allNetworks, includingSpam: true) + let allUserAssets = assetManager.getAllUserAssetsInNetworkAssets(networks: allNetworks, includingUserDeleted: true) // Filter `allTokens` to remove any tokens existing in `allUserAssets`. This is possible for ERC721 tokens in the registry without a `tokenId`, which requires the user to add as a custom token let allUserTokens = allUserAssets.flatMap(\.tokens) diff --git a/Sources/BraveWallet/Extensions/WalletColors.swift b/Sources/BraveWallet/Extensions/WalletColors.swift index b97f927fca4..b7c57b5d001 100644 --- a/Sources/BraveWallet/Extensions/WalletColors.swift +++ b/Sources/BraveWallet/Extensions/WalletColors.swift @@ -250,4 +250,11 @@ enum WalletV2Design { static let passwordMediumYellow = UIColor(rgb: 0xbd9600) static let passwordStrongGreen = UIColor(rgb: 0x31803e) + + static let spamNFTLabelBackground = UIColor( + red: 1, + green: 209 / 255, + blue: 207 / 255, + alpha: 1 + ) } diff --git a/Sources/BraveWallet/WalletStrings.swift b/Sources/BraveWallet/WalletStrings.swift index f5972caea0c..84c780c2874 100644 --- a/Sources/BraveWallet/WalletStrings.swift +++ b/Sources/BraveWallet/WalletStrings.swift @@ -4234,13 +4234,6 @@ extension Strings { value: "No hidden NFTs here yet.", comment: "The title of the empty state inside NFT tab under Hidden group." ) - public static let nftSpamPageEmptyTitle = NSLocalizedString( - "wallet.nftSpamPageEmptyTitle", - tableName: "BraveWallet", - bundle: .module, - value: "No NFTs have been marked as spam.", - comment: "The title of the empty state inside NFT tab under Spam group." - ) public static let nftPageEmptyDescription = NSLocalizedString( "wallet.nftPageEmptyDescription", tableName: "BraveWallet", @@ -4346,6 +4339,13 @@ extension Strings { value: "Import NFT", comment: "The title of the button that user clicks to add his/her first NFT" ) + public static let nftCollected = NSLocalizedString( + "wallet.nftCollected", + tableName: "BraveWallet", + bundle: .module, + value: "Collected", + comment: "The title of one of the dropdown options to group NFTs. This group will display all user's visible NFTs." + ) public static let nftHidden = NSLocalizedString( "wallet.nftHidden", tableName: "BraveWallet", @@ -4357,8 +4357,8 @@ extension Strings { "wallet.nftSpam", tableName: "BraveWallet", bundle: .module, - value: "Spam", - comment: "The title of one of the dropdown options to group NFTs. This group will display all user's marked spam NFTs and SimpleHash marked spam NFTs." + value: "Junk", + comment: "The title of an overlay on top left of the junk NFT grid." ) public static let nftUnhide = NSLocalizedString( "wallet.nftUnhide", @@ -4367,20 +4367,34 @@ extension Strings { value: "Unhide", comment: "The title of context button for user to unhide visible NFT." ) - public static let nftMoveToSpam = NSLocalizedString( - "wallet.nftMoveToSpam", - tableName: "BraveWallet", - bundle: .module, - value: "Move to Spam", - comment: "The title of context button for user to move a NFT to the `Spam` group." - ) public static let nftUnspam = NSLocalizedString( "wallet.nftUnspam", tableName: "BraveWallet", bundle: .module, - value: "Unspam", + value: "Mark as Not Junk", comment: "The title of context button for user to unspam a NFT." ) + public static let nftRemoveFromWallet = NSLocalizedString( + "wallet.nftRemoveFromWallet", + tableName: "BraveWallet", + bundle: .module, + value: "Don't Show in Wallet", + comment: "The title of context button for user to do not show a NFT in wallet at all." + ) + public static let nftRemoveFromWalletAlertTitle = NSLocalizedString( + "wallet.nftRemoveFromWalletAlertTitle", + tableName: "BraveWallet", + bundle: .module, + value: "Remove from Brave Wallet?", + comment: "The title of the alert when user attempts to remove an NFT from wallet." + ) + public static let nftRemoveFromWalletAlertDescription = NSLocalizedString( + "wallet.nftRemoveFromWalletAlertDescription", + tableName: "BraveWallet", + bundle: .module, + value: "NFT will be removed from Brave Wallet but will remain on the blockchain. If you remove it, then change your mind, you'll need to import it again manually.", + comment: "The description of the alert when user attempts to remove an NFT from wallet." + ) public static let selectTokenToSendTitle = NSLocalizedString( "wallet.selectTokenToSendTitle", tableName: "BraveWallet", diff --git a/Sources/BraveWallet/WalletUserAssetManager.swift b/Sources/BraveWallet/WalletUserAssetManager.swift index 312685c0d79..be0ec12a1ed 100644 --- a/Sources/BraveWallet/WalletUserAssetManager.swift +++ b/Sources/BraveWallet/WalletUserAssetManager.swift @@ -13,9 +13,11 @@ public protocol WalletUserAssetManagerType: AnyObject { /// Return all visible or all invisible user assets in form of `NetworkAssets` func getAllUserAssetsInNetworkAssetsByVisibility(networks: [BraveWallet.NetworkInfo], visible: Bool) -> [NetworkAssets] /// Return all user assets in form of `NetworkAssets` - func getAllUserAssetsInNetworkAssets(networks: [BraveWallet.NetworkInfo], includingSpam: Bool) -> [NetworkAssets] + func getAllUserAssetsInNetworkAssets(networks: [BraveWallet.NetworkInfo], includingUserDeleted: Bool) -> [NetworkAssets] /// Return all spam or non-spam user assets in form of `NetworkAssets` func getAllUserNFTs(networks: [BraveWallet.NetworkInfo], isSpam: Bool) -> [NetworkAssets] + /// Return all user marked deleted user assets + func getAllUserDeletedNFTs() -> [WalletUserAsset] /// Return a `WalletUserAsset` with a given `BraveWallet.BlockchainToken` func getUserAsset(_ asset: BraveWallet.BlockchainToken) -> WalletUserAsset? /// Add a `WalletUserAsset` representation of the given @@ -26,8 +28,8 @@ public protocol WalletUserAssetManagerType: AnyObject { func removeUserAsset(_ asset: BraveWallet.BlockchainToken, completion: (() -> Void)?) /// Remove an entire `WalletUserAssetGroup` with a given `groupId` func removeGroup(for groupId: String, completion: (() -> Void)?) - /// Update a `WalletUserAsset`'s visible and spam status - func updateUserAsset(for asset: BraveWallet.BlockchainToken, visible: Bool, isSpam: Bool, completion: (() -> Void)?) + /// Update a `WalletUserAsset`'s `visible`, `isSpam`, and `isDeletedByUser` status + func updateUserAsset(for asset: BraveWallet.BlockchainToken, visible: Bool, isSpam: Bool, isDeletedByUser: Bool, completion: (() -> Void)?) } public class WalletUserAssetManager: WalletUserAssetManagerType { @@ -44,13 +46,13 @@ public class WalletUserAssetManager: WalletUserAssetManagerType { } /// Return all user's assets stored in CoreData - public func getAllUserAssetsInNetworkAssets(networks: [BraveWallet.NetworkInfo], includingSpam: Bool) -> [NetworkAssets] { + public func getAllUserAssetsInNetworkAssets(networks: [BraveWallet.NetworkInfo], includingUserDeleted: Bool) -> [NetworkAssets] { var allUserAssets: [NetworkAssets] = [] for (index, network) in networks.enumerated() { let groupId = network.walletUserAssetGroupId if let walletUserAssets = WalletUserAssetGroup.getGroup(groupId: groupId)?.walletUserAssets?.filter({ - if !includingSpam { - return $0.isSpam == false + if !includingUserDeleted { + return $0.isDeletedByUser == false } return true }) { @@ -71,7 +73,7 @@ public class WalletUserAssetManager: WalletUserAssetManagerType { var allVisibleUserAssets: [NetworkAssets] = [] for (index, network) in networks.enumerated() { let groupId = network.walletUserAssetGroupId - if let walletUserAssets = WalletUserAssetGroup.getGroup(groupId: groupId)?.walletUserAssets?.filter({ $0.visible == visible && $0.isSpam == false }) { + if let walletUserAssets = WalletUserAssetGroup.getGroup(groupId: groupId)?.walletUserAssets?.filter({ $0.visible == visible && $0.isSpam == false && $0.isDeletedByUser == false }) { let networkAsset = NetworkAssets( network: network, tokens: walletUserAssets.map(\.blockchainToken), @@ -88,7 +90,7 @@ public class WalletUserAssetManager: WalletUserAssetManagerType { var allUserSpamAssets: [NetworkAssets] = [] for (index, network) in networks.enumerated() { let groupId = network.walletUserAssetGroupId - if let walletUserAssets = WalletUserAssetGroup.getGroup(groupId: groupId)?.walletUserAssets?.filter({ $0.isSpam == isSpam && ($0.isERC721 || $0.isNFT) }) { // Even though users can only spam/unspam NFTs, but we put the NFT filter here to make sure only NFTs are returned + if let walletUserAssets = WalletUserAssetGroup.getGroup(groupId: groupId)?.walletUserAssets?.filter({ $0.isSpam == isSpam && ($0.isERC721 || $0.isNFT) && $0.isDeletedByUser == false }) { // Even though users can only spam/unspam NFTs, but we put the NFT filter here to make sure only NFTs are returned let networkAsset = NetworkAssets( network: network, tokens: walletUserAssets.map(\.blockchainToken), @@ -100,16 +102,25 @@ public class WalletUserAssetManager: WalletUserAssetManagerType { return allUserSpamAssets.sorted(by: { $0.sortOrder < $1.sortOrder }) } + public func getAllUserDeletedNFTs() -> [WalletUserAsset] { + WalletUserAsset.getAllUserDeletedUserAssets() ?? [] + } + public func getUserAsset(_ asset: BraveWallet.BlockchainToken) -> WalletUserAsset? { WalletUserAsset.getUserAsset(asset: asset) } public func addUserAsset(_ asset: BraveWallet.BlockchainToken, completion: (() -> Void)?) { - guard WalletUserAsset.getUserAsset(asset: asset) == nil else { - completion?() - return + if let existedAsset = WalletUserAsset.getUserAsset(asset: asset) { + if existedAsset.isDeletedByUser { // this asset was added before but user marked as deleted after + WalletUserAsset.updateUserAsset(for: asset, visible: true, isSpam: false, isDeletedByUser: false, completion: completion) + } else { // this asset already exists + completion?() + return + } + } else { // asset does not exist in database + WalletUserAsset.addUserAsset(asset: asset, completion: completion) } - WalletUserAsset.addUserAsset(asset: asset, completion: completion) } public func removeUserAsset(_ asset: BraveWallet.BlockchainToken, completion: (() -> Void)?) { @@ -120,12 +131,14 @@ public class WalletUserAssetManager: WalletUserAssetManagerType { for asset: BraveWallet.BlockchainToken, visible: Bool, isSpam: Bool, + isDeletedByUser: Bool, completion: (() -> Void)? ) { WalletUserAsset.updateUserAsset( for: asset, visible: visible, isSpam: isSpam, + isDeletedByUser: isDeletedByUser, completion: completion ) } @@ -182,18 +195,19 @@ public class WalletUserAssetManager: WalletUserAssetManagerType { #if DEBUG public class TestableWalletUserAssetManager: WalletUserAssetManagerType { public var _getAllUserAssetsInNetworkAssetsByVisibility: ((_ networks: [BraveWallet.NetworkInfo], _ visible: Bool) -> [NetworkAssets])? - public var _getAllUserAssetsInNetworkAssets: ((_ networks: [BraveWallet.NetworkInfo], _ includingSpam: Bool) -> [NetworkAssets])? + public var _getAllUserAssetsInNetworkAssets: ((_ networks: [BraveWallet.NetworkInfo], _ includingUserDeleted: Bool) -> [NetworkAssets])? public var _getAllUserNFTs: ((_ networks: [BraveWallet.NetworkInfo], _ spamStatus: Bool) -> [NetworkAssets])? + public var _getAllUserDeletedNFTs: (() -> [WalletUserAsset])? public init() {} - public func getAllUserAssetsInNetworkAssets(networks: [BraveWallet.NetworkInfo], includingSpam: Bool) -> [NetworkAssets] { + public func getAllUserAssetsInNetworkAssets(networks: [BraveWallet.NetworkInfo], includingUserDeleted: Bool) -> [NetworkAssets] { let defaultAssets: [NetworkAssets] = [ NetworkAssets(network: .mockMainnet, tokens: [.previewToken], sortOrder: 0), NetworkAssets(network: .mockGoerli, tokens: [.previewToken], sortOrder: 1) ] let chainIds = networks.map { $0.chainId } - return _getAllUserAssetsInNetworkAssets?(networks, includingSpam) ?? defaultAssets.filter({ + return _getAllUserAssetsInNetworkAssets?(networks, includingUserDeleted) ?? defaultAssets.filter({ chainIds.contains($0.network.chainId) }) } @@ -206,6 +220,10 @@ public class TestableWalletUserAssetManager: WalletUserAssetManagerType { _getAllUserNFTs?(networks, isSpam) ?? [] } + public func getAllUserDeletedNFTs() -> [WalletUserAsset] { + _getAllUserDeletedNFTs?() ?? [] + } + public func getUserAsset(_ asset: BraveWallet.BlockchainToken) -> WalletUserAsset? { return nil } @@ -223,6 +241,7 @@ public class TestableWalletUserAssetManager: WalletUserAssetManagerType { for asset: BraveWallet.BlockchainToken, visible: Bool, isSpam: Bool, + isDeletedByUser: Bool, completion: (() -> Void)? ) { } diff --git a/Sources/Data/models/Model.xcdatamodeld/.xccurrentversion b/Sources/Data/models/Model.xcdatamodeld/.xccurrentversion index e0b9d8358df..483172cbd80 100644 --- a/Sources/Data/models/Model.xcdatamodeld/.xccurrentversion +++ b/Sources/Data/models/Model.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Model23.xcdatamodel + Model24.xcdatamodel diff --git a/Sources/Data/models/Model.xcdatamodeld/Model24.xcdatamodel/contents b/Sources/Data/models/Model.xcdatamodeld/Model24.xcdatamodel/contents new file mode 100644 index 00000000000..de5274c117f --- /dev/null +++ b/Sources/Data/models/Model.xcdatamodeld/Model24.xcdatamodel/contents @@ -0,0 +1,234 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/Data/models/WalletUserAsset.swift b/Sources/Data/models/WalletUserAsset.swift index 215d22656f5..5a06b87504f 100644 --- a/Sources/Data/models/WalletUserAsset.swift +++ b/Sources/Data/models/WalletUserAsset.swift @@ -25,6 +25,7 @@ public final class WalletUserAsset: NSManagedObject, CRUD { @NSManaged public var coingeckoId: String @NSManaged public var chainId: String @NSManaged public var coin: Int16 + @NSManaged public var isDeletedByUser: Bool @NSManaged public var walletUserAssetGroup: WalletUserAssetGroup? public var blockchainToken: BraveWallet.BlockchainToken { @@ -80,6 +81,7 @@ public final class WalletUserAsset: NSManagedObject, CRUD { self.coingeckoId = asset.coingeckoId self.chainId = asset.chainId self.coin = Int16(asset.coin.rawValue) + // `isDeletedByUser` has a default value `NO` } public static func getUserAsset(asset: BraveWallet.BlockchainToken, context: NSManagedObjectContext? = nil) -> WalletUserAsset? { @@ -87,7 +89,11 @@ public final class WalletUserAsset: NSManagedObject, CRUD { } public static func getAllVisibleUserAssets(context: NSManagedObjectContext? = nil) -> [WalletUserAsset]? { - WalletUserAsset.all(where: NSPredicate(format: "visible = true"), context: context ?? DataController.viewContext) + WalletUserAsset.all(where: NSPredicate(format: "visible = true AND isDeletedByUser == false"), context: context ?? DataController.viewContext) + } + + public static func getAllUserDeletedUserAssets(context: NSManagedObjectContext? = nil) -> [WalletUserAsset]? { + WalletUserAsset.all(where: NSPredicate(format: "isDeletedByUser == true"), context: context ?? DataController.viewContext) } public static func migrateVisibleAssets(_ assets: [String: [BraveWallet.BlockchainToken]], completion: (() -> Void)? = nil) { @@ -112,18 +118,21 @@ public final class WalletUserAsset: NSManagedObject, CRUD { for asset: BraveWallet.BlockchainToken, visible: Bool, isSpam: Bool, + isDeletedByUser: Bool, completion: (() -> Void)? = nil ) { DataController.perform(context: .new(inMemory: false), save: false) { context in if let asset = WalletUserAsset.first(where: NSPredicate(format: "contractAddress == %@ AND chainId == %@ AND symbol == %@ AND tokenId == %@", asset.contractAddress, asset.chainId, asset.symbol, asset.tokenId), context: context) { asset.visible = visible asset.isSpam = isSpam + asset.isDeletedByUser = isDeletedByUser } else { let groupId = asset.walletUserAssetGroupId let group = WalletUserAssetGroup.getGroup(groupId: groupId, context: context) ?? WalletUserAssetGroup(context: context, groupId: groupId) let visibleAsset = WalletUserAsset(context: context, asset: asset) visibleAsset.visible = visible visibleAsset.isSpam = isSpam + visibleAsset.isDeletedByUser = isDeletedByUser visibleAsset.walletUserAssetGroup = group } @@ -140,7 +149,7 @@ public final class WalletUserAsset: NSManagedObject, CRUD { let groupId = asset.walletUserAssetGroupId let group = WalletUserAssetGroup.getGroup(groupId: groupId, context: context) ?? WalletUserAssetGroup(context: context, groupId: groupId) let visibleAsset = WalletUserAsset(context: context, asset: asset) - visibleAsset.visible = true + visibleAsset.visible = true // (`isSpam` and `isDeletedByUser` have a default value `NO`) visibleAsset.walletUserAssetGroup = group WalletUserAsset.saveContext(context) diff --git a/Sources/DesignSystem/Icons/Symbols.xcassets/leo.warning.triangle-outline.symbolset/Contents.json b/Sources/DesignSystem/Icons/Symbols.xcassets/leo.warning.triangle-outline.symbolset/Contents.json new file mode 100644 index 00000000000..2f415ce6e4c --- /dev/null +++ b/Sources/DesignSystem/Icons/Symbols.xcassets/leo.warning.triangle-outline.symbolset/Contents.json @@ -0,0 +1,11 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "idiom" : "universal" + } + ] +} diff --git a/Tests/DataTests/WalletUserAssetTests.swift b/Tests/DataTests/WalletUserAssetTests.swift index 1f50e59e307..7f63bcbc128 100644 --- a/Tests/DataTests/WalletUserAssetTests.swift +++ b/Tests/DataTests/WalletUserAssetTests.swift @@ -38,9 +38,10 @@ class WalletUserAssetTests: CoreDataTestCase { XCTAssertTrue(userAsset.visible) XCTAssertFalse(userAsset.isSpam) + XCTAssertFalse(userAsset.isDeletedByUser) backgroundSaveAndWaitForExpectation { - WalletUserAsset.updateUserAsset(for: asset, visible: false, isSpam: true) + WalletUserAsset.updateUserAsset(for: asset, visible: false, isSpam: true, isDeletedByUser: true) } DataController.viewContext.refreshAllObjects() @@ -49,6 +50,7 @@ class WalletUserAssetTests: CoreDataTestCase { XCTAssertFalse(userAsset.visible) XCTAssertTrue(userAsset.isSpam) + XCTAssertTrue(userAsset.isDeletedByUser) } func testGetAllVisibleAssets() { @@ -57,7 +59,7 @@ class WalletUserAssetTests: CoreDataTestCase { createAndWait(asset: asset3) backgroundSaveAndWaitForExpectation { - WalletUserAsset.updateUserAsset(for: asset2, visible: false, isSpam: false) + WalletUserAsset.updateUserAsset(for: asset2, visible: false, isSpam: false, isDeletedByUser: false) } DataController.viewContext.refreshAllObjects() @@ -66,6 +68,20 @@ class WalletUserAssetTests: CoreDataTestCase { XCTAssertEqual(allAssets!.count, 2) } + func testGetAllVisibleAssetsAfterDeletion() { + createAndWait(asset: asset) + createAndWait(asset: asset2) + + backgroundSaveAndWaitForExpectation { + WalletUserAsset.updateUserAsset(for: asset2, visible: false, isSpam: false, isDeletedByUser: true) + } + + DataController.viewContext.refreshAllObjects() + let allDeletedAssets = WalletUserAsset.getAllUserDeletedUserAssets() + XCTAssertNotNil(allDeletedAssets) + XCTAssertEqual(allDeletedAssets!.count, 1) + } + // MARK: - Deleting func testRemoveAsset() {