diff --git a/Sources/BraveWallet/Crypto/Stores/CryptoStore.swift b/Sources/BraveWallet/Crypto/Stores/CryptoStore.swift index a70fe3d6e33..d9925d519dc 100644 --- a/Sources/BraveWallet/Crypto/Stores/CryptoStore.swift +++ b/Sources/BraveWallet/Crypto/Stores/CryptoStore.swift @@ -537,6 +537,30 @@ public class CryptoStore: ObservableObject, WalletObserverStore { } } + private var signMessageRequestStore: SignMessageRequestStore? + func signMessageRequestStore(for requests: [BraveWallet.SignMessageRequest]) -> SignMessageRequestStore { + if let store = signMessageRequestStore { + DispatchQueue.main.async { // don't update in view body computation + store.requests = requests + } + return store + } + let store = SignMessageRequestStore( + requests: requests, + keyringService: keyringService, + rpcService: rpcService, + assetRatioService: assetRatioService, + blockchainRegistry: blockchainRegistry, + userAssetManager: userAssetManager + ) + self.signMessageRequestStore = store + return store + } + + func closeSignMessageRequestStore() { + self.signMessageRequestStore = nil + } + public private(set) lazy var settingsStore = SettingsStore( keyringService: keyringService, walletService: walletService, diff --git a/Sources/BraveWallet/Crypto/Stores/SignMessageRequestStore.swift b/Sources/BraveWallet/Crypto/Stores/SignMessageRequestStore.swift new file mode 100644 index 00000000000..1acd0fb367f --- /dev/null +++ b/Sources/BraveWallet/Crypto/Stores/SignMessageRequestStore.swift @@ -0,0 +1,183 @@ +// Copyright 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import BraveCore +import SwiftUI + +class SignMessageRequestStore: ObservableObject { + + @Published var requests: [BraveWallet.SignMessageRequest] { + didSet { + guard requests != oldValue else { return } + update() + } + } + + /// The current request on display + var currentRequest: BraveWallet.SignMessageRequest { + requests[requestIndex] + } + + /// Current request index + @Published var requestIndex: Int = 0 + /// A map between request index and a boolean value indicates this request message needs pilcrow formating/ + /// Key is the request id. This property is assigned by the view, because we need the view height to determine. + @Published var needPilcrowFormatted: [Int32: Bool] = [:] + /// A map between request index and a boolean value indicates this request message is displayed as + /// its original content. Key is the request id. + @Published var showOrignalMessage: [Int32: Bool] = [:] + /// EthSwapDetails for CoW swap requests. Key is the request id. + @Published var ethSwapDetails: [Int32: EthSwapDetails] = [:] + + private let keyringService: BraveWalletKeyringService + private let rpcService: BraveWalletJsonRpcService + private let assetRatioService: BraveWalletAssetRatioService + private let blockchainRegistry: BraveWalletBlockchainRegistry + private let assetManager: WalletUserAssetManagerType + + /// Cancellable for the last running `update()` Task. + private var updateTask: Task<(), Never>? + /// Cache for storing `BlockchainToken`s that are not in user assets or our token registry. + /// This could occur with a dapp creating a transaction. + private var tokenInfoCache: [BraveWallet.BlockchainToken] = [] + + init( + requests: [BraveWallet.SignMessageRequest], + keyringService: BraveWalletKeyringService, + rpcService: BraveWalletJsonRpcService, + assetRatioService: BraveWalletAssetRatioService, + blockchainRegistry: BraveWalletBlockchainRegistry, + userAssetManager: WalletUserAssetManagerType + ) { + self.requests = requests + self.keyringService = keyringService + self.rpcService = rpcService + self.assetRatioService = assetRatioService + self.blockchainRegistry = blockchainRegistry + self.assetManager = userAssetManager + } + + func update() { + self.updateTask?.cancel() + self.updateTask = Task { @MainActor in + // setup default values + for request in requests { + if showOrignalMessage[request.id] == nil { + showOrignalMessage[request.id] = true + } + if needPilcrowFormatted[request.id] == nil { + needPilcrowFormatted[request.id] = false + } + } + + let cowSwapRequests: [(id: Int32, cowSwapOrder: BraveWallet.CowSwapOrder, chainId: String)] = self.requests + .compactMap { request in + guard let cowSwapOrder = request.signData.ethSignTypedData?.meta?.cowSwapOrder else { + return nil + } + return (request.id, cowSwapOrder, request.chainId) + } + guard !cowSwapRequests.isEmpty else { return } + + let allNetworks = await rpcService.allNetworksForSupportedCoins(respectTestnetPreference: false) + let userAssets = assetManager.getAllUserAssetsInNetworkAssets( + networks: allNetworks, + includingUserDeleted: true + ).flatMap(\.tokens) + let allTokens = await blockchainRegistry.allTokens(in: allNetworks).flatMap(\.tokens) + + let findToken: (String, String) async -> BraveWallet.BlockchainToken? = { [tokenInfoCache] contractAddress, chainId in + userAssets.first(where: { + $0.contractAddress.caseInsensitiveCompare(contractAddress) == .orderedSame + && $0.chainId.caseInsensitiveCompare(chainId) == .orderedSame + }) ?? allTokens.first(where: { + $0.contractAddress.caseInsensitiveCompare(contractAddress) == .orderedSame + && $0.chainId.caseInsensitiveCompare(chainId) == .orderedSame + }) ?? tokenInfoCache.first(where: { + $0.contractAddress.caseInsensitiveCompare(contractAddress) == .orderedSame + && $0.chainId.caseInsensitiveCompare(chainId) == .orderedSame + }) + } + + // Gather unknown token info to fetch if needed. + var unknownTokenPairs: Set = .init() + + for cowSwapRequest in cowSwapRequests { + let requestId = cowSwapRequest.id + let cowSwapOrder = cowSwapRequest.cowSwapOrder + let chainId = cowSwapRequest.chainId + guard let network = allNetworks.first(where: { $0.chainId.caseInsensitiveCompare(chainId) == .orderedSame }) else { + return + } + + let formatter = WeiFormatter(decimalFormatStyle: .decimals(precision: Int(network.decimals))) + + let fromToken: BraveWallet.BlockchainToken? = await findToken(cowSwapOrder.sellToken, chainId) + let fromTokenDecimals = Int(fromToken?.decimals ?? network.decimals) + if fromToken == nil { + unknownTokenPairs.insert(.init(contractAddress: cowSwapOrder.sellToken, chainId: chainId)) + } + + let toToken: BraveWallet.BlockchainToken? = await findToken(cowSwapOrder.buyToken, chainId) + let toTokenDecimals = Int(toToken?.decimals ?? network.decimals) + if toToken == nil { + unknownTokenPairs.insert(.init(contractAddress: cowSwapOrder.buyToken, chainId: chainId)) + } + + let formattedSellAmount = formatter.decimalString(for: cowSwapOrder.sellAmount, radix: .decimal, decimals: fromTokenDecimals)?.trimmingTrailingZeros ?? "" + let formattedMinBuyAmount = formatter.decimalString(for: cowSwapOrder.buyAmount, radix: .decimal, decimals: toTokenDecimals)?.trimmingTrailingZeros ?? "" + + let details = EthSwapDetails( + fromToken: fromToken, + fromValue: cowSwapOrder.sellAmount, + fromAmount: formattedSellAmount, + fromFiat: nil, // not required for display + toToken: toToken, + minBuyValue: cowSwapOrder.buyToken, + minBuyAmount: formattedMinBuyAmount, + minBuyAmountFiat: nil, // not required for display + gasFee: nil // sign request, no gas fee + ) + self.ethSwapDetails[requestId] = details + } + if !unknownTokenPairs.isEmpty { + fetchUnknownTokens(Array(unknownTokenPairs)) + } + } + } + + /// Advance to the next (or first if displaying the last) sign message request. + func next() { + if requestIndex + 1 < requests.count { + if let nextRequestId = requests[safe: requestIndex + 1]?.id, + showOrignalMessage[nextRequestId] == nil { + // if we have not previously assigned a `showOriginalMessage` + // value for the next request, assign it the default value now. + showOrignalMessage[nextRequestId] = true + } + requestIndex = requestIndex + 1 + } else { + requestIndex = 0 + } + } + + private func fetchUnknownTokens(_ pairs: [ContractAddressChainIdPair]) { + Task { @MainActor in + // filter out tokens we have already fetched + let filteredPairs = pairs.filter { pair in + !tokenInfoCache.contains(where: { + $0.contractAddress.caseInsensitiveCompare(pair.contractAddress) != .orderedSame + && $0.chainId.caseInsensitiveCompare(pair.chainId) != .orderedSame + }) + } + guard !filteredPairs.isEmpty else { + return + } + let tokens = await rpcService.fetchEthTokens(for: pairs) + tokenInfoCache.append(contentsOf: tokens) + update() + } + } +} diff --git a/Sources/BraveWallet/Crypto/Transaction Confirmations/PendingTransactionView.swift b/Sources/BraveWallet/Crypto/Transaction Confirmations/PendingTransactionView.swift index d36070f305c..c5cbb9bdcf3 100644 --- a/Sources/BraveWallet/Crypto/Transaction Confirmations/PendingTransactionView.swift +++ b/Sources/BraveWallet/Crypto/Transaction Confirmations/PendingTransactionView.swift @@ -454,9 +454,8 @@ struct PendingTransactionView: View { // Current Active Transaction info if confirmationStore.activeParsedTransaction.transaction.txType == .ethSwap { - SwapTransactionConfirmationView( + SaferSignTransactionContainerView( parsedTransaction: confirmationStore.activeParsedTransaction, - network: confirmationStore.network ?? .init(), editGasFeeTapped: { isShowingGas = true }, diff --git a/Sources/BraveWallet/Crypto/Transaction Confirmations/SaferSignTransactionContainerView.swift b/Sources/BraveWallet/Crypto/Transaction Confirmations/SaferSignTransactionContainerView.swift new file mode 100644 index 00000000000..df8250ebc6e --- /dev/null +++ b/Sources/BraveWallet/Crypto/Transaction Confirmations/SaferSignTransactionContainerView.swift @@ -0,0 +1,164 @@ +// Copyright 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import SwiftUI +import BraveCore +import BigNumber +import Strings +import DesignSystem + +struct SaferSignTransactionContainerView: View { + /// The OriginInfo that created the transaction + let originInfo: BraveWallet.OriginInfo? + /// The network the transaction belongs to + let network: BraveWallet.NetworkInfo? + + /// The address of the account making the swap + let fromAddress: String? + /// The name of the account + let namedFromAddress: String? + + /// The token being swapped from. + let fromToken: BraveWallet.BlockchainToken? + /// The amount of the `tokToken` being swapped. + let fromAmount: String? + + /// The token being swapped for. + let toToken: BraveWallet.BlockchainToken? + /// Minimum amount being bought of the `toToken`. + let minBuyAmount: String? + /// The gas fee for the transaction + let gasFee: GasFee? + + let editGasFeeTapped: () -> Void + let advancedSettingsTapped: () -> Void + + @Environment(\.pixelLength) private var pixelLength + @ScaledMetric private var faviconSize = 48 + private let maxFaviconSize: CGFloat = 72 + @ScaledMetric private var assetNetworkIconSize: CGFloat = 15 + private let maxAssetNetworkIconSize: CGFloat = 20 + + init( + parsedTransaction: ParsedTransaction, + editGasFeeTapped: @escaping () -> Void, + advancedSettingsTapped: @escaping () -> Void + ) { + self.originInfo = parsedTransaction.transaction.originInfo + self.network = parsedTransaction.network + self.fromAddress = parsedTransaction.fromAddress + self.namedFromAddress = parsedTransaction.namedFromAddress + if case .ethSwap(let details) = parsedTransaction.details { + self.fromToken = details.fromToken + self.fromAmount = details.fromAmount + self.toToken = details.toToken + self.minBuyAmount = details.minBuyAmount + } else { + self.fromToken = nil + self.fromAmount = nil + self.toToken = nil + self.minBuyAmount = nil + } + self.gasFee = parsedTransaction.gasFee + self.editGasFeeTapped = editGasFeeTapped + self.advancedSettingsTapped = advancedSettingsTapped + } + + var body: some View { + VStack { + originAndFavicon + + SaferSignTransactionView( + network: network, + fromAddress: fromAddress, + namedFromAddress: namedFromAddress, + receiverAddress: nil, + namedReceiverAddress: nil, + fromToken: fromToken, + fromTokenContractAddress: fromToken?.contractAddress, + fromAmount: fromAmount, + toToken: toToken, + toTokenContractAddress: toToken?.contractAddress, + minBuyAmount: minBuyAmount + ) + + networkFeeSection + } + } + + private var originAndFavicon: some View { + VStack { + if let originInfo = originInfo { + Group { + if originInfo.isBraveWalletOrigin { + Image(uiImage: UIImage(sharedNamed: "brave.logo")!) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(Color(.braveOrange)) + } else { + if let url = URL(string: originInfo.originSpec) { + FaviconReader(url: url) { image in + if let image = image { + Image(uiImage: image) + .resizable() + .scaledToFit() + } else { + Circle() + .stroke(Color(.braveSeparator), lineWidth: pixelLength) + } + } + .background(Color(.braveDisabled)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + } + } + .frame(width: min(faviconSize, maxFaviconSize), height: min(faviconSize, maxFaviconSize)) + + Text(originInfo: originInfo) + .foregroundColor(Color(.braveLabel)) + .font(.subheadline) + .multilineTextAlignment(.center) + .padding(.top, 8) + } + } + } + + private var networkFeeSection: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(Strings.Wallet.swapConfirmationNetworkFee) + .fontWeight(.medium) + .foregroundColor(Color(.secondaryBraveLabel)) + Spacer() + Button(action: advancedSettingsTapped) { + Image(systemName: "gearshape") + .foregroundColor(Color(.secondaryBraveLabel)) + } + .buttonStyle(.plain) + } + HStack { + Group { + if let image = network?.nativeTokenLogoImage { + Image(uiImage: image) + .resizable() + } else { + Circle() + .stroke(Color(.braveSeparator)) + } + } + .frame(width: min(assetNetworkIconSize, maxAssetNetworkIconSize), height: min(assetNetworkIconSize, maxAssetNetworkIconSize)) + Text(gasFee?.fiat ?? "") + .foregroundColor(Color(.braveLabel)) + Button(action: editGasFeeTapped) { + Text(Strings.Wallet.editGasFeeButtonTitle) + .fontWeight(.semibold) + .foregroundColor(Color(.braveBlurpleTint)) + } + Spacer() + } + } + .frame(maxWidth: .infinity) + } +} diff --git a/Sources/BraveWallet/Crypto/Transaction Confirmations/SaferSignTransactionView.swift b/Sources/BraveWallet/Crypto/Transaction Confirmations/SaferSignTransactionView.swift new file mode 100644 index 00000000000..310389ef6d5 --- /dev/null +++ b/Sources/BraveWallet/Crypto/Transaction Confirmations/SaferSignTransactionView.swift @@ -0,0 +1,301 @@ +// Copyright 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import SwiftUI +import BraveCore +import BigNumber +import Strings +import DesignSystem + +struct SaferSignTransactionView: View { + /// The network the transaction belongs to + let network: BraveWallet.NetworkInfo? + + /// The address of the account making the swap + let fromAddress: String? + /// The name of the account + let namedFromAddress: String? + + /// The address of the recipient (applicable to CoW Swap) + let receiverAddress: String? + /// The named address of the recipient (applicable to CoW Swap) + let namedReceiverAddress: String? + + /// The token being swapped from. + let fromToken: BraveWallet.BlockchainToken? + /// The contract address of the from token + let fromTokenContractAddress: String? + /// The amount of the `tokToken` being swapped. + let fromAmount: String? + + /// The token being swapped for. + let toToken: BraveWallet.BlockchainToken? + /// The contract address of the to token + let toTokenContractAddress: String? + /// Minimum amount being bought of the `toToken`. + let minBuyAmount: String? + + @Environment(\.sizeCategory) private var sizeCategory + @Environment(\.colorScheme) private var colorScheme + @Environment(\.pixelLength) private var pixelLength + @ScaledMetric private var assetIconSize: CGFloat = 40 + private let maxAssetIconSize: CGFloat = 50 + @ScaledMetric private var assetNetworkIconSize: CGFloat = 15 + private let maxAssetNetworkIconSize: CGFloat = 20 + + init( + network: BraveWallet.NetworkInfo?, + fromAddress: String?, + namedFromAddress: String?, + receiverAddress: String?, + namedReceiverAddress: String?, + fromToken: BraveWallet.BlockchainToken?, + fromTokenContractAddress: String?, + fromAmount: String?, + toToken: BraveWallet.BlockchainToken?, + toTokenContractAddress: String?, + minBuyAmount: String? + ) { + self.network = network + self.fromAddress = fromAddress + self.namedFromAddress = namedFromAddress + self.receiverAddress = receiverAddress + self.namedReceiverAddress = namedReceiverAddress + self.fromToken = fromToken + self.fromTokenContractAddress = fromTokenContractAddress + self.fromAmount = fromAmount + self.toToken = toToken + self.toTokenContractAddress = toTokenContractAddress + self.minBuyAmount = minBuyAmount + } + + var body: some View { + VStack(spacing: 20) { + tokenValueComparison + + tokensView + } + .padding(.bottom, 20) + } + + @ViewBuilder private var tokenValueComparison: some View { + if let minBuyAmount = Double(minBuyAmount ?? ""), + let sellAmount = Double(fromAmount ?? ""), + minBuyAmount != 0, sellAmount != 0 { + let calculated = minBuyAmount / sellAmount + let display = String(format: "1 \(fromToken?.symbol ?? "") = %.6f \(toToken?.symbol ?? "")", calculated) + Text(display) + .font(.callout) + .foregroundColor(Color(.secondaryBraveLabel)) + } + } + + private var fromTokenView: some View { + VStack { + HStack { + Text(Strings.Wallet.swapConfirmationYouSpend) + .fontWeight(.medium) + .foregroundColor(Color(.secondaryBraveLabel)) + Spacer() + AddressView(address: fromAddress ?? "") { + HStack(spacing: 2) { + Blockie(address: fromAddress ?? "") + .frame(width: 15, height: 15) + Text(namedFromAddress ?? "") + .font(.footnote) + } + .padding(4) + .overlay( + RoundedRectangle(cornerSize: CGSize(width: 4, height: 4)) + .stroke(Color(.braveSeparator), lineWidth: pixelLength) + ) + } + } + TokenRow( + title: "\(fromAmount ?? "") \(fromToken?.symbol ?? "")", + subTitle: String.localizedStringWithFormat(Strings.Wallet.swapConfirmationNetworkDesc, network?.chainName ?? ""), + token: fromToken, + tokenContractAddress: fromTokenContractAddress, + network: network, + assetIconSize: assetIconSize, + maxAssetIconSize: maxAssetIconSize, + assetNetworkIconSize: assetNetworkIconSize, + maxAssetNetworkIconSize: maxAssetNetworkIconSize + ) + } + } + + private var toTokenView: some View { + VStack { + HStack { + Text(Strings.Wallet.swapConfirmationYoullReceive) + .fontWeight(.medium) + .foregroundColor(Color(.secondaryBraveLabel)) + Spacer() + if let receiverAddress { + AddressView(address: receiverAddress) { + HStack(spacing: 2) { + Blockie(address: receiverAddress) + .frame(width: 15, height: 15) + Text(namedReceiverAddress ?? receiverAddress.truncatedAddress) + .font(.footnote) + } + .padding(4) + .overlay( + RoundedRectangle(cornerSize: CGSize(width: 4, height: 4)) + .stroke(Color(.braveSeparator), lineWidth: pixelLength) + ) + } + } + } + TokenRow( + title: "\(minBuyAmount ?? "") \(toToken?.symbol ?? "")", + subTitle: String.localizedStringWithFormat(Strings.Wallet.swapConfirmationNetworkDesc, network?.chainName ?? ""), + token: toToken, + tokenContractAddress: toTokenContractAddress, + network: network, + assetIconSize: assetIconSize, + maxAssetIconSize: maxAssetIconSize, + assetNetworkIconSize: assetNetworkIconSize, + maxAssetNetworkIconSize: maxAssetNetworkIconSize + ) + } + } + + private var arrowView: some View { + Circle() + .stroke(Color(.braveSeparator), lineWidth: pixelLength) + .background(Color(.secondaryBraveGroupedBackground)) + .frame(width: 32, height: 32) + .overlay( + Image(systemName: "arrow.down") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + .foregroundColor(Color(white: 0.75)) + ) + } + + private var tokensView: some View { + VStack { + fromTokenView + .padding(.init(top: 10, leading: 15, bottom: 10, trailing: 10)) + Divider() + .overlay(arrowView, alignment: .center) + .padding(.vertical, 5) + toTokenView + .padding(.init(top: 10, leading: 15, bottom: 20, trailing: 10)) + } + .background(Color(.secondaryBraveGroupedBackground).cornerRadius(10)) + } +} + +private struct TokenRow: View { + let title: String + let subTitle: String + let token: BraveWallet.BlockchainToken? + let tokenContractAddress: String? + let network: BraveWallet.NetworkInfo? + let assetIconSize: CGFloat + let maxAssetIconSize: CGFloat + let assetNetworkIconSize: CGFloat + let maxAssetNetworkIconSize: CGFloat + + @Environment(\.openURL) private var openWalletURL + + var body: some View { + HStack { + if let token = token, let network = network { + AssetIconView( + token: token, + network: network, + shouldShowNetworkIcon: true, + length: assetIconSize, + maxLength: maxAssetIconSize, + networkSymbolLength: assetNetworkIconSize, + maxNetworkSymbolLength: maxAssetNetworkIconSize + ) + } else { + Circle() + .stroke(Color(.braveSeparator)) + .frame(width: assetIconSize, height: assetIconSize) + } + VStack(alignment: .leading) { + Text(title) + .font(.callout) + .fontWeight(.semibold) + Text(subTitle) + .font(.footnote) + } + Spacer() + if let toTokenContractAddress = tokenContractAddress ?? token?.contractAddress, + let explorerUrl = network?.tokenBlockExplorerURL(toTokenContractAddress) { + Button(action: { + openWalletURL(explorerUrl) + }) { + Label(Strings.Wallet.viewOnBlockExplorer, systemImage: "arrow.up.forward.square") + .labelStyle(.iconOnly) + .foregroundColor(Color(braveSystemName: .iconInteractive)) + } + } + } + } +} + +#if DEBUG +struct SaferSignTransactionView_Previews: PreviewProvider { + static var transaction: BraveWallet.TransactionInfo { + let transaction: BraveWallet.TransactionInfo = .init() + transaction.originInfo = .init( + originSpec: WalletConstants.braveWalletOriginSpec, + eTldPlusOne: "" + ) + return transaction + } + + static var previews: some View { + let parsedTransaction: ParsedTransaction = .init( + transaction: transaction, + namedFromAddress: "Ethereum Account 1", + fromAddress: BraveWallet.AccountInfo.previewAccount.address, + namedToAddress: "0x Exchange", + toAddress: "0x1111111111222222222233333333334444444444", + network: .mockMainnet, + details: .ethSwap(.init( + fromToken: .mockUSDCToken, + fromValue: "1.000004", + fromAmount: "1", + fromFiat: "$1.04", + toToken: .previewDaiToken, + minBuyValue: "0.994798", + minBuyAmount: "0.994798", + minBuyAmountFiat: "$0.99", + gasFee: .init(fee: "100", fiat: "$0.009081") + )) + ) + Group { + ForEach(ColorScheme.allCases, id: \.self) { colorScheme in + ScrollView { + SaferSignTransactionView( + network: parsedTransaction.network, + fromAddress: parsedTransaction.fromAddress, + namedFromAddress: parsedTransaction.namedFromAddress, + receiverAddress: nil, + namedReceiverAddress: nil, + fromToken: .mockUSDCToken, + fromTokenContractAddress: BraveWallet.BlockchainToken.mockUSDCToken.contractAddress, + fromAmount: "1", + toToken: .previewDaiToken, + toTokenContractAddress: BraveWallet.BlockchainToken.previewDaiToken.contractAddress, + minBuyAmount: "0.994798" + ) + } + .background(Color(.braveGroupedBackground).ignoresSafeArea()) + .preferredColorScheme(colorScheme) + } + } + } +} +#endif diff --git a/Sources/BraveWallet/Crypto/Transaction Confirmations/SwapTransactionConfirmationView.swift b/Sources/BraveWallet/Crypto/Transaction Confirmations/SwapTransactionConfirmationView.swift deleted file mode 100644 index 52b405217fc..00000000000 --- a/Sources/BraveWallet/Crypto/Transaction Confirmations/SwapTransactionConfirmationView.swift +++ /dev/null @@ -1,299 +0,0 @@ -// Copyright 2023 The Brave Authors. All rights reserved. -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -import SwiftUI -import BraveCore -import BigNumber -import Strings -import DesignSystem - -struct SwapTransactionConfirmationView: View { - - let parsedTransaction: ParsedTransaction? - let network: BraveWallet.NetworkInfo? - - let editGasFeeTapped: () -> Void - let advancedSettingsTapped: () -> Void - - @Environment(\.sizeCategory) private var sizeCategory - @Environment(\.colorScheme) private var colorScheme - @Environment(\.pixelLength) private var pixelLength - @ScaledMetric private var faviconSize = 48 - private let maxFaviconSize: CGFloat = 72 - @ScaledMetric private var assetIconSize: CGFloat = 40 - private let maxAssetIconSize: CGFloat = 50 - @ScaledMetric private var assetNetworkIconSize: CGFloat = 15 - private let maxAssetNetworkIconSize: CGFloat = 20 - - var body: some View { - VStack(spacing: 20) { - originAndFavicon - - tokenValueComparison - - tokensView - - networkFeeView - } - .padding(.bottom, 20) - } - - private var originAndFavicon: some View { - VStack { - if let originInfo = parsedTransaction?.transaction.originInfo { - Group { - if originInfo.isBraveWalletOrigin { - Image(uiImage: UIImage(sharedNamed: "brave.logo")!) - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(Color(.braveOrange)) - } else { - if let url = URL(string: originInfo.originSpec) { - FaviconReader(url: url) { image in - if let image = image { - Image(uiImage: image) - .resizable() - .scaledToFit() - } else { - Circle() - .stroke(Color(.braveSeparator), lineWidth: pixelLength) - } - } - .background(Color(.braveDisabled)) - .clipShape(RoundedRectangle(cornerRadius: 4)) - } - } - } - .frame(width: min(faviconSize, maxFaviconSize), height: min(faviconSize, maxFaviconSize)) - - Text(originInfo: originInfo) - .foregroundColor(Color(.braveLabel)) - .font(.subheadline) - .multilineTextAlignment(.center) - .padding(.top, 8) - } - } - } - - @ViewBuilder private var tokenValueComparison: some View { - if let minBuyAmount = Double(parsedTransaction?.ethSwap?.minBuyAmount ?? ""), - let sellAmount = Double( parsedTransaction?.ethSwap?.fromAmount ?? ""), - minBuyAmount != 0, sellAmount != 0 { - let calculated = minBuyAmount / sellAmount - let display = String(format: "1 \(parsedTransaction?.ethSwap?.fromToken?.symbol ?? "") = %.6f \(parsedTransaction?.ethSwap?.toToken?.symbol ?? "")", calculated) - Text(display) - .font(.callout) - .foregroundColor(Color(.secondaryBraveLabel)) - } - } - - private var fromTokenView: some View { - VStack { - HStack { - Text(Strings.Wallet.swapConfirmationYouSpend) - .fontWeight(.medium) - .foregroundColor(Color(.secondaryBraveLabel)) - Spacer() - AddressView(address: parsedTransaction?.fromAddress ?? "") { - HStack(spacing: 2) { - Blockie(address: parsedTransaction?.fromAddress ?? "") - .frame(width: 15, height: 15) - Text(parsedTransaction?.namedFromAddress ?? "") - .font(.footnote) - } - .padding(4) - .overlay( - RoundedRectangle(cornerSize: CGSize(width: 4, height: 4)) - .stroke(Color(.braveSeparator), lineWidth: pixelLength) - ) - } - } - TokenRow( - title: "\(parsedTransaction?.ethSwap?.fromAmount ?? "") \(parsedTransaction?.ethSwap?.fromToken?.symbol ?? "")", - subTitle: String.localizedStringWithFormat(Strings.Wallet.swapConfirmationNetworkDesc, network?.chainName ?? ""), - token: parsedTransaction?.ethSwap?.fromToken, - network: network, - assetIconSize: assetIconSize, - maxAssetIconSize: maxAssetIconSize, - assetNetworkIconSize: assetNetworkIconSize, - maxAssetNetworkIconSize: maxAssetNetworkIconSize - ) - } - } - - private var toTokenView: some View { - VStack { - HStack { - Text(Strings.Wallet.swapConfirmationYoullReceive) - .fontWeight(.medium) - .foregroundColor(Color(.secondaryBraveLabel)) - Spacer() - } - TokenRow( - title: "\(parsedTransaction?.ethSwap?.minBuyAmount ?? "") \(parsedTransaction?.ethSwap?.toToken?.symbol ?? "")", - subTitle: String.localizedStringWithFormat(Strings.Wallet.swapConfirmationNetworkDesc, network?.chainName ?? ""), - token: parsedTransaction?.ethSwap?.toToken, - network: network, - assetIconSize: assetIconSize, - maxAssetIconSize: maxAssetIconSize, - assetNetworkIconSize: assetNetworkIconSize, - maxAssetNetworkIconSize: maxAssetNetworkIconSize - ) - } - } - - private var arrowView: some View { - Circle() - .stroke(Color(.braveSeparator), lineWidth: pixelLength) - .background(Color(.secondaryBraveGroupedBackground)) - .frame(width: 32, height: 32) - .overlay( - Image(systemName: "arrow.down") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 16, height: 16) - .foregroundColor(Color(white: 0.75)) - ) - } - - private var tokensView: some View { - VStack { - fromTokenView - .padding(.init(top: 10, leading: 15, bottom: 10, trailing: 10)) - Divider() - .overlay(arrowView, alignment: .center) - .padding(.vertical, 5) - toTokenView - .padding(.init(top: 10, leading: 15, bottom: 20, trailing: 10)) - } - .background(Color(.secondaryBraveGroupedBackground).cornerRadius(10)) - } - - private var networkFeeView: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(Strings.Wallet.swapConfirmationNetworkFee) - .fontWeight(.medium) - .foregroundColor(Color(.secondaryBraveLabel)) - Spacer() - Button(action: advancedSettingsTapped) { - Image(systemName: "gearshape") - .foregroundColor(Color(.secondaryBraveLabel)) - } - .buttonStyle(.plain) - } - HStack { - Group { - if let image = network?.nativeTokenLogoImage { - Image(uiImage: image) - .resizable() - } else { - Circle() - .stroke(Color(.braveSeparator)) - } - } - .frame(width: min(assetNetworkIconSize, maxAssetNetworkIconSize), height: min(assetNetworkIconSize, maxAssetNetworkIconSize)) - Text(parsedTransaction?.gasFee?.fiat ?? "") - .foregroundColor(Color(.braveLabel)) - Button(action: editGasFeeTapped) { - Text(Strings.Wallet.editGasFeeButtonTitle) - .fontWeight(.semibold) - .foregroundColor(Color(.braveBlurpleTint)) - } - Spacer() - } - } - .frame(maxWidth: .infinity) - } - - private struct TokenRow: View { - let title: String - let subTitle: String - let token: BraveWallet.BlockchainToken? - let network: BraveWallet.NetworkInfo? - let assetIconSize: CGFloat - let maxAssetIconSize: CGFloat - let assetNetworkIconSize: CGFloat - let maxAssetNetworkIconSize: CGFloat - - var body: some View { - HStack { - if let token = token, let network = network { - AssetIconView( - token: token, - network: network, - shouldShowNetworkIcon: true, - length: assetIconSize, - maxLength: maxAssetIconSize, - networkSymbolLength: assetNetworkIconSize, - maxNetworkSymbolLength: maxAssetNetworkIconSize - ) - } else { - Circle() - .stroke(Color(.braveSeparator)) - .frame(width: assetIconSize, height: assetIconSize) - } - VStack(alignment: .leading) { - Text(title) - .font(.callout) - .fontWeight(.semibold) - Text(subTitle) - .font(.footnote) - } - Spacer() - } - } - } -} - -#if DEBUG -struct SwapTransactionConfirmationView_Previews: PreviewProvider { - static var transaction: BraveWallet.TransactionInfo { - let transaction: BraveWallet.TransactionInfo = .init() - transaction.originInfo = .init( - originSpec: WalletConstants.braveWalletOriginSpec, - eTldPlusOne: "" - ) - return transaction - } - - static var previews: some View { - let parsedTransaction: ParsedTransaction = .init( - transaction: transaction, - namedFromAddress: "Ethereum Account 1", - fromAddress: BraveWallet.AccountInfo.previewAccount.address, - namedToAddress: "0x Exchange", - toAddress: "0x1111111111222222222233333333334444444444", - network: .mockMainnet, - details: .ethSwap(.init( - fromToken: .mockUSDCToken, - fromValue: "1.000004", - fromAmount: "1", - fromFiat: "$1.04", - toToken: .previewDaiToken, - minBuyValue: "0.994798", - minBuyAmount: "0.994798", - minBuyAmountFiat: "$0.99", - gasFee: .init(fee: "100", fiat: "$0.009081") - )) - ) - Group { - ForEach(ColorScheme.allCases, id: \.self) { colorScheme in - ScrollView { - SwapTransactionConfirmationView( - parsedTransaction: parsedTransaction, - network: .mockPolygon, - editGasFeeTapped: {}, - advancedSettingsTapped: {} - ) - Spacer() - } - .background(Color(.braveGroupedBackground).ignoresSafeArea()) - .preferredColorScheme(colorScheme) - } - } - } -} -#endif diff --git a/Sources/BraveWallet/Extensions/BraveWalletExtensions.swift b/Sources/BraveWallet/Extensions/BraveWalletExtensions.swift index e061bc06ddf..0bec691a21d 100644 --- a/Sources/BraveWallet/Extensions/BraveWalletExtensions.swift +++ b/Sources/BraveWallet/Extensions/BraveWalletExtensions.swift @@ -322,6 +322,14 @@ extension BraveWallet.NetworkInfo { } return nil } + + func tokenBlockExplorerURL(_ contractAddress: String) -> URL? { + if let explorerURL = blockExplorerUrls.first, + let tokenURL = URL(string: "\(explorerURL)/token/\(contractAddress)") { + return tokenURL + } + return nil + } } extension BraveWallet.BlockchainToken { diff --git a/Sources/BraveWallet/Extensions/RpcServiceExtensions.swift b/Sources/BraveWallet/Extensions/RpcServiceExtensions.swift index ef9948b7fbf..a28289b5240 100644 --- a/Sources/BraveWallet/Extensions/RpcServiceExtensions.swift +++ b/Sources/BraveWallet/Extensions/RpcServiceExtensions.swift @@ -437,7 +437,7 @@ extension BraveWalletJsonRpcService { } } -struct ContractAddressChainIdPair: Equatable { +struct ContractAddressChainIdPair: Equatable, Hashable { let contractAddress: String let chainId: String } diff --git a/Sources/BraveWallet/Panels/RequestContainerView.swift b/Sources/BraveWallet/Panels/RequestContainerView.swift index 435109a4a0b..9f5d83707d6 100644 --- a/Sources/BraveWallet/Panels/RequestContainerView.swift +++ b/Sources/BraveWallet/Panels/RequestContainerView.swift @@ -56,12 +56,15 @@ struct RequestContainerView: View { ) case let .signMessage(requests): SignMessageRequestContainerView( - requests: requests, + store: cryptoStore.signMessageRequestStore(for: requests), keyringStore: keyringStore, cryptoStore: cryptoStore, networkStore: cryptoStore.networkStore, onDismiss: onDismiss ) + .onDisappear { + cryptoStore.closeSignMessageRequestStore() + } case let .signMessageError(signMessageErrors): SignMessageErrorView( signMessageErrors: signMessageErrors, diff --git a/Sources/BraveWallet/Panels/Signature Request/SaferSignMessageRequestContainerView.swift b/Sources/BraveWallet/Panels/Signature Request/SaferSignMessageRequestContainerView.swift new file mode 100644 index 00000000000..9d64f0c3233 --- /dev/null +++ b/Sources/BraveWallet/Panels/Signature Request/SaferSignMessageRequestContainerView.swift @@ -0,0 +1,238 @@ +// Copyright 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import SwiftUI +import BraveCore +import DesignSystem + +struct SaferSignMessageRequestContainerView: View { + + let account: BraveWallet.AccountInfo + let request: BraveWallet.SignMessageRequest + let network: BraveWallet.NetworkInfo? + let requestIndex: Int + let requestCount: Int + + let namedFromAddress: String + let receiverAddress: String + let namedReceiverAddress: String + let cowSwapOrder: BraveWallet.CowSwapOrder + let ethSwapDetails: EthSwapDetails? + + /// A map between request id and a boolean value indicates this request message needs pilcrow formating. + @Binding var needPilcrowFormatted: [Int32: Bool] + /// A map between request id and a boolean value indicates this request message is displayed as + /// its original content. + @Binding var showOrignalMessage: [Int32: Bool] + + var nextTapped: () -> Void + var action: (_ approved: Bool) -> Void + + @State private var isShowingDetails: Bool = false + + @Environment(\.sizeCategory) private var sizeCategory + @Environment(\.pixelLength) private var pixelLength + @ScaledMetric private var faviconSize = 48 + private let maxFaviconSize: CGFloat = 72 + @ScaledMetric private var assetNetworkIconSize: CGFloat = 15 + private let maxAssetNetworkIconSize: CGFloat = 20 + + var body: some View { + ScrollView { + VStack { + requestsHeader + + originAndFavicon + + Spacer(minLength: 20) + + if isShowingDetails { + SignMessageRequestContentView( + request: request, + needPilcrowFormatted: $needPilcrowFormatted, + showOrignalMessage: $showOrignalMessage + ) + // match spacing from comparison in `SaferSignTransactionView` + .padding(.vertical, 20) + } else { + SaferSignTransactionView( + network: network, + fromAddress: account.address, + namedFromAddress: namedFromAddress, + receiverAddress: receiverAddress, + namedReceiverAddress: namedReceiverAddress, + fromToken: ethSwapDetails?.fromToken, + fromTokenContractAddress: cowSwapOrder.sellToken, + fromAmount: ethSwapDetails?.fromAmount, + toToken: ethSwapDetails?.toToken, + toTokenContractAddress: cowSwapOrder.buyToken, + minBuyAmount: ethSwapDetails?.minBuyAmount + ) + + } + + networkFeeSection + + buttonsContainer + .padding(.top) + .opacity(sizeCategory.isAccessibilityCategory ? 0 : 1) + .accessibility(hidden: sizeCategory.isAccessibilityCategory) + } + .padding() + } + .overlay(alignment: .bottom) { + if sizeCategory.isAccessibilityCategory { + buttonsContainer + .frame(maxWidth: .infinity) + .padding(.top) + .background( + LinearGradient( + stops: [ + .init(color: Color(.braveGroupedBackground).opacity(0), location: 0), + .init(color: Color(.braveGroupedBackground).opacity(1), location: 0.05), + .init(color: Color(.braveGroupedBackground).opacity(1), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + .allowsHitTesting(false) + ) + } + } + .navigationTitle(Strings.Wallet.swapConfirmationTitle) + .navigationBarTitleDisplayMode(.inline) + } + + /// Header containing the current requests network chain name, and a `1 of N` & `Next` button when there are multiple requests. + private var requestsHeader: some View { + HStack { + if let network { + Text(network.chainName) + .font(.callout) + .foregroundColor(Color(.braveLabel)) + } + Spacer() + if requestCount > 1 { + NextIndexButton( + currentIndex: requestIndex, + count: requestCount, + nextTapped: nextTapped + ) + } + } + } + + private var originAndFavicon: some View { + VStack { + Group { + if request.originInfo.isBraveWalletOrigin { + Image(uiImage: UIImage(sharedNamed: "brave.logo")!) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(Color(.braveOrange)) + } else { + if let url = URL(string: request.originInfo.originSpec) { + FaviconReader(url: url) { image in + if let image = image { + Image(uiImage: image) + .resizable() + .scaledToFit() + } else { + Circle() + .stroke(Color(.braveSeparator), lineWidth: pixelLength) + } + } + .background(Color(.braveDisabled)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + } + } + .frame(width: min(faviconSize, maxFaviconSize), height: min(faviconSize, maxFaviconSize)) + + Text(originInfo: request.originInfo) + .foregroundColor(Color(.braveLabel)) + .font(.subheadline) + .multilineTextAlignment(.center) + .padding(.top, 8) + } + } + + private var networkFeeSection: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(Strings.Wallet.swapConfirmationNetworkFee) + .fontWeight(.medium) + .foregroundColor(Color(.secondaryBraveLabel)) + HStack { + Group { + if let image = network?.nativeTokenLogoImage { + Image(uiImage: image) + .resizable() + } else { + Circle() + .stroke(Color(.braveSeparator)) + } + } + .frame(width: min(assetNetworkIconSize, maxAssetNetworkIconSize), height: min(assetNetworkIconSize, maxAssetNetworkIconSize)) + Text(Strings.Wallet.braveSwapFree) + .foregroundColor(Color(.braveLabel)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + Spacer() + Button(action: { + isShowingDetails.toggle() + }) { + Text(detailsButtonTitle) + .fontWeight(.medium) + .foregroundColor(Color(braveSystemName: .textInteractive)) + } + } + } + .frame(maxWidth: .infinity) + } + + private var detailsButtonTitle: String { + if isShowingDetails { + return Strings.Wallet.hideDetailsButtonTitle + } + return Strings.Wallet.confirmationViewModeDetails + } + + /// Cancel & Sign button container + @ViewBuilder private var buttonsContainer: some View { + if sizeCategory.isAccessibilityCategory { + VStack { + buttons + } + } else { + HStack { + buttons + } + } + } + + /// Cancel and Sign buttons + @ViewBuilder private var buttons: some View { + Button(action: { // cancel + action(false) + }) { + Label(Strings.cancelButtonTitle, systemImage: "xmark") + .imageScale(.large) + } + .buttonStyle(BraveOutlineButtonStyle(size: .large)) + .disabled(requestIndex != 0) + Button(action: { // approve + action(true) + }) { + Label(Strings.Wallet.sign, braveSystemImage: "leo.key") + .imageScale(.large) + } + .buttonStyle(BraveFilledButtonStyle(size: .large)) + .disabled(requestIndex != 0) + } +} diff --git a/Sources/BraveWallet/Panels/Signature Request/SignMessageRequestContainerView.swift b/Sources/BraveWallet/Panels/Signature Request/SignMessageRequestContainerView.swift index e35d0a151ea..99e333d0f3f 100644 --- a/Sources/BraveWallet/Panels/Signature Request/SignMessageRequestContainerView.swift +++ b/Sources/BraveWallet/Panels/Signature Request/SignMessageRequestContainerView.swift @@ -11,54 +11,62 @@ import DesignSystem /// View for displaying an array of `SignMessageRequest`s` struct SignMessageRequestContainerView: View { - var requests: [BraveWallet.SignMessageRequest] + @ObservedObject var store: SignMessageRequestStore @ObservedObject var keyringStore: KeyringStore var cryptoStore: CryptoStore @ObservedObject var networkStore: NetworkStore var onDismiss: () -> Void - - @State private var requestIndex: Int = 0 - - /// A map between request index and a boolean value indicates this request message needs pilcrow formating - @State private var needPilcrowFormatted: [Int32: Bool] = [0: false] - /// A map between request index and a boolean value indicates this request message is displayed as - /// its original content - @State private var showOrignalMessage: [Int32: Bool] = [0: true] - - /// The current request - private var currentRequest: BraveWallet.SignMessageRequest { - requests[requestIndex] - } /// The account for the current request private var currentRequestAccount: BraveWallet.AccountInfo { - keyringStore.allAccounts.first(where: { $0.address == currentRequest.accountId.address }) ?? keyringStore.selectedAccount + keyringStore.allAccounts.first(where: { $0.address == store.currentRequest.accountId.address }) ?? keyringStore.selectedAccount } /// The network for the current request private var currentRequestNetwork: BraveWallet.NetworkInfo? { - networkStore.allChains.first(where: { $0.chainId == currentRequest.chainId }) + networkStore.allChains.first(where: { $0.chainId == store.currentRequest.chainId }) } var body: some View { Group { - if let ethSiweData = currentRequest.signData.ethSiweData { + if let ethSiweData = store.currentRequest.signData.ethSiweData { SignInWithEthereumView( account: currentRequestAccount, - originInfo: currentRequest.originInfo, + originInfo: store.currentRequest.originInfo, message: ethSiweData, action: handleAction(approved:) ) + } else if let cowSwapOrder = store.currentRequest.signData.ethSignTypedData?.meta?.cowSwapOrder { + SaferSignMessageRequestContainerView( + account: currentRequestAccount, + request: store.currentRequest, + network: currentRequestNetwork, + requestIndex: store.requestIndex, + requestCount: store.requests.count, + namedFromAddress: NamedAddresses.name( + for: currentRequestAccount.address, accounts: keyringStore.allAccounts + ), + receiverAddress: cowSwapOrder.receiver, + namedReceiverAddress: NamedAddresses.name( + for: cowSwapOrder.receiver, accounts: keyringStore.allAccounts + ), + cowSwapOrder: cowSwapOrder, + ethSwapDetails: store.ethSwapDetails[store.currentRequest.id], + needPilcrowFormatted: $store.needPilcrowFormatted, + showOrignalMessage: $store.showOrignalMessage, + nextTapped: store.next, + action: handleAction(approved:) + ) } else { // ethSignTypedData, ethStandardSignData, solanaSignData SignMessageRequestView( account: currentRequestAccount, - request: currentRequest, + request: store.currentRequest, network: currentRequestNetwork, - requestIndex: requestIndex, - requestCount: requests.count, - needPilcrowFormatted: $needPilcrowFormatted, - showOrignalMessage: $showOrignalMessage, - nextTapped: next, + requestIndex: store.requestIndex, + requestCount: store.requests.count, + needPilcrowFormatted: $store.needPilcrowFormatted, + showOrignalMessage: $store.showOrignalMessage, + nextTapped: store.next, action: handleAction(approved:) ) } @@ -66,26 +74,14 @@ struct SignMessageRequestContainerView: View { .navigationBarTitleDisplayMode(.inline) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(braveSystemName: .containerHighlight)) - } - - /// Advance to the next (or first if displaying the last) sign message request. - func next() { - if requestIndex + 1 < requests.count { - if let nextRequestId = requests[safe: requestIndex + 1]?.id, - showOrignalMessage[nextRequestId] == nil { - // if we have not previously assigned a `showOriginalMessage` - // value for the next request, assign it the default value now. - showOrignalMessage[nextRequestId] = true - } - requestIndex = requestIndex + 1 - } else { - requestIndex = 0 + .onAppear { + store.update() } } private func handleAction(approved: Bool) { - cryptoStore.handleWebpageRequestResponse(.signMessage(approved: approved, id: currentRequest.id)) - if requests.count <= 1 { + cryptoStore.handleWebpageRequestResponse(.signMessage(approved: approved, id: store.currentRequest.id)) + if store.requests.count <= 1 { onDismiss() } } diff --git a/Sources/BraveWallet/Panels/Signature Request/SignMessageRequestContentView.swift b/Sources/BraveWallet/Panels/Signature Request/SignMessageRequestContentView.swift new file mode 100644 index 00000000000..fe9eed64c02 --- /dev/null +++ b/Sources/BraveWallet/Panels/Signature Request/SignMessageRequestContentView.swift @@ -0,0 +1,220 @@ +// Copyright 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import SwiftUI +import BraveStrings +import BraveCore +import DesignSystem + +/// Content view for displaying message (and domain if applicable), as well as warning view +/// if consecutive new lines or unknown characters are found. +struct SignMessageRequestContentView: View { + + let request: BraveWallet.SignMessageRequest + + /// A map between request id and a boolean value indicates this request message needs pilcrow formating. + @Binding var needPilcrowFormatted: [Int32: Bool] + /// A map between request id and a boolean value indicates this request message is displayed as + /// its original content. + @Binding var showOrignalMessage: [Int32: Bool] + + private let staticTextViewHeight: CGFloat = 200 + + /// Request display text, used as fallback. + private var requestDisplayText: String { + if requestDomain.isEmpty { + return requestMessage + } + return """ + \(Strings.Wallet.signatureRequestDomainTitle) + \(requestDomain) + + \(Strings.Wallet.signatureRequestMessageTitle) + \(requestMessage) + """ + } + + /// Formatted request display text. Will display with bold `Domain` / `Message` headers if domain is non-empty. + private var requestDisplayAttributedText: NSAttributedString? { + let metrics = UIFontMetrics(forTextStyle: .body) + let desc = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) + let regularFont = metrics.scaledFont(for: UIFont.systemFont(ofSize: desc.pointSize, weight: .regular)) + let regularAttributes: [NSAttributedString.Key: Any] = [ + .font: regularFont, .foregroundColor: UIColor.braveLabel] + if requestDomain.isEmpty { + // if we don't show domain, we don't need the titles so we + // can fallback to `requestDisplayText` string for perf reasons + return nil + } + let boldFont = metrics.scaledFont(for: UIFont.systemFont(ofSize: desc.pointSize, weight: .bold)) + let boldAttributes: [NSAttributedString.Key: Any] = [ + .font: boldFont, .foregroundColor: UIColor.braveLabel] + + let domainTitle = NSAttributedString(string: Strings.Wallet.signatureRequestDomainTitle, attributes: boldAttributes) + let domain = NSAttributedString(string: "\n\(requestDomain)\n\n", attributes: regularAttributes) + let messageTitle = NSAttributedString(string: Strings.Wallet.signatureRequestMessageTitle, attributes: boldAttributes) + let message = NSAttributedString(string: "\n\(requestMessage)", attributes: regularAttributes) + + let attrString = NSMutableAttributedString(attributedString: domainTitle) + attrString.append(domain) + attrString.append(messageTitle) + attrString.append(message) + return attrString + } + + private var currentRequestDomain: String? { + request.signData.ethSignTypedData?.domain + } + + private var requestDomain: String { + guard let domain = currentRequestDomain else { return "" } + if showOrignalMessage[request.id] == true { + return domain + } else { + let uuid = UUID() + var result = domain + if needPilcrowFormatted[request.id] == true { + var copy = domain + while copy.range(of: "\\n{2,}", options: .regularExpression) != nil { + if let range = copy.range(of: "\\n{2,}", options: .regularExpression) { + let newlines = String(copy[range]) + result.replaceSubrange(range, with: "\n\(uuid.uuidString) <\(newlines.count)>\n") + copy.replaceSubrange(range, with: "\n\(uuid.uuidString) <\(newlines.count)>\n") + } + } + } + if domain.hasUnknownUnicode { + result = result.printableWithUnknownUnicode + } + + return result.replacingOccurrences(of: uuid.uuidString, with: "\u{00B6}") + } + } + + private var currentRequestMessage: String? { + if let ethSignTypedData = request.signData.ethSignTypedData { + return ethSignTypedData.message + } else if let ethStandardSignData = request.signData.ethStandardSignData { + return ethStandardSignData.message + } else if let solanaSignData = request.signData.solanaSignData { + return solanaSignData.message + } else { // ethSiweData displayed via `SignInWithEthereumView` + return nil + } + } + + private var requestMessage: String { + guard let message = currentRequestMessage else { + return "" + } + if showOrignalMessage[request.id] == true { + return message + } else { + let uuid = UUID() + var result = message + if needPilcrowFormatted[request.id] == true { + var copy = message + while copy.range(of: "\\n{3,}", options: .regularExpression) != nil { + if let range = copy.range(of: "\\n{3,}", options: .regularExpression) { + let newlines = String(copy[range]) + result.replaceSubrange(range, with: "\n\(uuid.uuidString) <\(newlines.count)>\n") + copy.replaceSubrange(range, with: "\n\(uuid.uuidString) <\(newlines.count)>\n") + } + } + } + if message.hasUnknownUnicode { + result = result.printableWithUnknownUnicode + } + + return result.replacingOccurrences(of: uuid.uuidString, with: "\u{00B6}") + } + } + + var body: some View { + VStack(spacing: 20) { + if needPilcrowFormatted[request.id] == true || currentRequestMessage?.hasUnknownUnicode == true { + MessageWarningView( + needsPilcrowFormatted: needPilcrowFormatted[request.id] == true, + hasUnknownUnicode: currentRequestMessage?.hasUnknownUnicode == true, + isShowingOriginalMessage: showOrignalMessage[request.id] == true, + action: { + let value = showOrignalMessage[request.id] ?? false + showOrignalMessage[request.id] = !value + } + ) + } + + StaticTextView(text: requestDisplayText, attributedText: requestDisplayAttributedText, isMonospaced: false) + .frame(maxWidth: .infinity) + .frame(height: staticTextViewHeight) + .background( + Color(.tertiaryBraveGroupedBackground), + in: RoundedRectangle(cornerRadius: 5, style: .continuous) + ) + .padding() + .background( + Color(.secondaryBraveGroupedBackground), + in: RoundedRectangle(cornerRadius: 10, style: .continuous) + ) + .introspectTextView { textView in + // A flash to show users message is overflowing the text view (related to issue https://github.com/brave/brave-ios/issues/6277) + if showOrignalMessage[request.id] == true { + let currentRequestHasConsecutiveNewLines = currentRequestDomain?.hasConsecutiveNewLines == true || currentRequestMessage?.hasConsecutiveNewLines == true + if textView.contentSize.height > staticTextViewHeight && currentRequestHasConsecutiveNewLines { + needPilcrowFormatted[request.id] = true + textView.flashScrollIndicators() + } else { + needPilcrowFormatted[request.id] = false + } + } + } + } + } +} + +/// Yellow background warning view with a button to toggle between showing original message and encoded message. +private struct MessageWarningView: View { + + let needsPilcrowFormatted: Bool + let hasUnknownUnicode: Bool + let isShowingOriginalMessage: Bool + let action: () -> Void + + @Environment(\.pixelLength) private var pixelLength + + var body: some View { + VStack(spacing: 8) { + if needsPilcrowFormatted { + Text("\(Image(systemName: "exclamationmark.triangle.fill")) \(Strings.Wallet.signMessageConsecutiveNewlineWarning)") + .font(.subheadline.weight(.medium)) + .foregroundColor(Color(.braveLabel)) + .multilineTextAlignment(.center) + } + if hasUnknownUnicode { + Text("\(Image(systemName: "exclamationmark.triangle.fill")) \(Strings.Wallet.signMessageRequestUnknownUnicodeWarning)") + .font(.subheadline.weight(.medium)) + .foregroundColor(Color(.braveLabel)) + .multilineTextAlignment(.center) + } + Button { + action() + } label: { + Text(isShowingOriginalMessage ? Strings.Wallet.signMessageShowUnknownUnicode : Strings.Wallet.signMessageShowOriginalMessage) + .font(.subheadline) + .foregroundColor(Color(.braveBlurpleTint)) + } + } + .padding(12) + .frame(maxWidth: .infinity) + .background( + Color(.braveWarningBackground) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Color(.braveWarningBorder), style: StrokeStyle(lineWidth: pixelLength)) + ) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + ) + } +} diff --git a/Sources/BraveWallet/Panels/Signature Request/SignMessageRequestView.swift b/Sources/BraveWallet/Panels/Signature Request/SignMessageRequestView.swift index 45987d16a7b..799bb9f173d 100644 --- a/Sources/BraveWallet/Panels/Signature Request/SignMessageRequestView.swift +++ b/Sources/BraveWallet/Panels/Signature Request/SignMessageRequestView.swift @@ -8,7 +8,7 @@ import BraveStrings import BraveCore import DesignSystem -/// View for showing `SignMessageRequest` for +/// View for showing `SignMessageRequest` for /// ethSignTypedData, ethStandardSignData, & solanaSignData struct SignMessageRequestView: View { @@ -28,117 +28,6 @@ struct SignMessageRequestView: View { @Environment(\.sizeCategory) private var sizeCategory @ScaledMetric private var blockieSize = 54 private let maxBlockieSize: CGFloat = 108 - private let staticTextViewHeight: CGFloat = 200 - - /// Request display text, used as fallback. - private var requestDisplayText: String { - if requestDomain.isEmpty { - return requestMessage - } - return """ - \(Strings.Wallet.signatureRequestDomainTitle) - \(requestDomain) - - \(Strings.Wallet.signatureRequestMessageTitle) - \(requestMessage) - """ - } - - /// Formatted request display text. Will display with bold `Domain` / `Message` headers if domain is non-empty. - private var requestDisplayAttributedText: NSAttributedString? { - let metrics = UIFontMetrics(forTextStyle: .body) - let desc = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) - let regularFont = metrics.scaledFont(for: UIFont.systemFont(ofSize: desc.pointSize, weight: .regular)) - let regularAttributes: [NSAttributedString.Key: Any] = [ - .font: regularFont, .foregroundColor: UIColor.braveLabel] - if requestDomain.isEmpty { - // if we don't show domain, we don't need the titles so we - // can fallback to `requestDisplayText` string for perf reasons - return nil - } - let boldFont = metrics.scaledFont(for: UIFont.systemFont(ofSize: desc.pointSize, weight: .bold)) - let boldAttributes: [NSAttributedString.Key: Any] = [ - .font: boldFont, .foregroundColor: UIColor.braveLabel] - - let domainTitle = NSAttributedString(string: Strings.Wallet.signatureRequestDomainTitle, attributes: boldAttributes) - let domain = NSAttributedString(string: "\n\(requestDomain)\n\n", attributes: regularAttributes) - let messageTitle = NSAttributedString(string: Strings.Wallet.signatureRequestMessageTitle, attributes: boldAttributes) - let message = NSAttributedString(string: "\n\(requestMessage)", attributes: regularAttributes) - - let attrString = NSMutableAttributedString(attributedString: domainTitle) - attrString.append(domain) - attrString.append(messageTitle) - attrString.append(message) - return attrString - } - - private var currentRequestDomain: String? { - request.signData.ethSignTypedData?.domain - } - - private var requestDomain: String { - guard let domain = currentRequestDomain else { return "" } - if showOrignalMessage[request.id] == true { - return domain - } else { - let uuid = UUID() - var result = domain - if needPilcrowFormatted[request.id] == true { - var copy = domain - while copy.range(of: "\\n{2,}", options: .regularExpression) != nil { - if let range = copy.range(of: "\\n{2,}", options: .regularExpression) { - let newlines = String(copy[range]) - result.replaceSubrange(range, with: "\n\(uuid.uuidString) <\(newlines.count)>\n") - copy.replaceSubrange(range, with: "\n\(uuid.uuidString) <\(newlines.count)>\n") - } - } - } - if domain.hasUnknownUnicode { - result = result.printableWithUnknownUnicode - } - - return result.replacingOccurrences(of: uuid.uuidString, with: "\u{00B6}") - } - } - - private var currentRequestMessage: String? { - if let ethSignTypedData = request.signData.ethSignTypedData { - return ethSignTypedData.message - } else if let ethStandardSignData = request.signData.ethStandardSignData { - return ethStandardSignData.message - } else if let solanaSignData = request.signData.solanaSignData { - return solanaSignData.message - } else { // ethSiweData displayed via `SignInWithEthereumView` - return nil - } - } - - private var requestMessage: String { - guard let message = currentRequestMessage else { - return "" - } - if showOrignalMessage[request.id] == true { - return message - } else { - let uuid = UUID() - var result = message - if needPilcrowFormatted[request.id] == true { - var copy = message - while copy.range(of: "\\n{3,}", options: .regularExpression) != nil { - if let range = copy.range(of: "\\n{3,}", options: .regularExpression) { - let newlines = String(copy[range]) - result.replaceSubrange(range, with: "\n\(uuid.uuidString) <\(newlines.count)>\n") - copy.replaceSubrange(range, with: "\n\(uuid.uuidString) <\(newlines.count)>\n") - } - } - } - if message.hasUnknownUnicode { - result = result.printableWithUnknownUnicode - } - - return result.replacingOccurrences(of: uuid.uuidString, with: "\u{00B6}") - } - } /// Header containing the current requests network chain name, and a `1 of N` & `Next` button when there are multiple requests. private var requestsHeader: some View { @@ -192,44 +81,14 @@ struct SignMessageRequestView: View { Text(Strings.Wallet.signatureRequestSubtitle) .font(.headline) .foregroundColor(Color(.bravePrimary)) - - if needPilcrowFormatted[request.id] == true || currentRequestMessage?.hasUnknownUnicode == true { - MessageWarningView( - needsPilcrowFormatted: needPilcrowFormatted[request.id] == true, - hasUnknownUnicode: currentRequestMessage?.hasUnknownUnicode == true, - isShowingOriginalMessage: showOrignalMessage[request.id] == true, - action: { - let value = showOrignalMessage[request.id] ?? false - showOrignalMessage[request.id] = !value - } - ) - } } .padding(.vertical, 32) - StaticTextView(text: requestDisplayText, attributedText: requestDisplayAttributedText, isMonospaced: false) - .frame(maxWidth: .infinity) - .frame(height: staticTextViewHeight) - .background( - Color(.tertiaryBraveGroupedBackground), - in: RoundedRectangle(cornerRadius: 5, style: .continuous) - ) - .padding() - .background( - Color(.secondaryBraveGroupedBackground), - in: RoundedRectangle(cornerRadius: 10, style: .continuous) - ) - .introspectTextView { textView in - // A flash to show users message is overflowing the text view (related to issue https://github.com/brave/brave-ios/issues/6277) - if showOrignalMessage[request.id] == true { - let currentRequestHasConsecutiveNewLines = currentRequestDomain?.hasConsecutiveNewLines == true || currentRequestMessage?.hasConsecutiveNewLines == true - if textView.contentSize.height > staticTextViewHeight && currentRequestHasConsecutiveNewLines { - needPilcrowFormatted[request.id] = true - textView.flashScrollIndicators() - } else { - needPilcrowFormatted[request.id] = false - } - } - } + + SignMessageRequestContentView( + request: request, + needPilcrowFormatted: $needPilcrowFormatted, + showOrignalMessage: $showOrignalMessage + ) buttonsContainer .padding(.top) @@ -296,53 +155,8 @@ struct SignMessageRequestView: View { } } -/// Yellow background warning view with a button to toggle between showing original message and encoded message. -private struct MessageWarningView: View { - - let needsPilcrowFormatted: Bool - let hasUnknownUnicode: Bool - let isShowingOriginalMessage: Bool - let action: () -> Void - - @Environment(\.pixelLength) private var pixelLength - - var body: some View { - VStack(spacing: 8) { - if needsPilcrowFormatted { - Text("\(Image(systemName: "exclamationmark.triangle.fill")) \(Strings.Wallet.signMessageConsecutiveNewlineWarning)") - .font(.subheadline.weight(.medium)) - .foregroundColor(Color(.braveLabel)) - .multilineTextAlignment(.center) - } - if hasUnknownUnicode { - Text("\(Image(systemName: "exclamationmark.triangle.fill")) \(Strings.Wallet.signMessageRequestUnknownUnicodeWarning)") - .font(.subheadline.weight(.medium)) - .foregroundColor(Color(.braveLabel)) - .multilineTextAlignment(.center) - } - Button { - action() - } label: { - Text(isShowingOriginalMessage ? Strings.Wallet.signMessageShowUnknownUnicode : Strings.Wallet.signMessageShowOriginalMessage) - .font(.subheadline) - .foregroundColor(Color(.braveBlurpleTint)) - } - } - .padding(12) - .frame(maxWidth: .infinity) - .background( - Color(.braveWarningBackground) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(Color(.braveWarningBorder), style: StrokeStyle(lineWidth: pixelLength)) - ) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - ) - } -} - /// View that displays the current index, total number of items and a `Next` button to move to next index. -private struct NextIndexButton: View { +struct NextIndexButton: View { let currentIndex: Int let count: Int diff --git a/Sources/BraveWallet/WalletStrings.swift b/Sources/BraveWallet/WalletStrings.swift index 9f970965ee9..6e9982ddda1 100644 --- a/Sources/BraveWallet/WalletStrings.swift +++ b/Sources/BraveWallet/WalletStrings.swift @@ -127,6 +127,13 @@ extension Strings { value: "Details", comment: "A button title which when pressed displays a new screen with additional details/information" ) + public static let hideDetailsButtonTitle = NSLocalizedString( + "wallet.hideDetailsButtonTitle", + tableName: "BraveWallet", + bundle: .module, + value: "Hide Details", + comment: "A button title which when pressed hides the details screen." + ) public static let renameButtonTitle = NSLocalizedString( "wallet.renameButtonTitle", tableName: "BraveWallet",