From e27f2ec66797e31899971c4364d9456d028f0193 Mon Sep 17 00:00:00 2001 From: Ivan Belyakov Date: Mon, 20 Feb 2023 13:57:45 +0300 Subject: [PATCH] feat(@desktop/wallet): update saved addresses list Fixes #8599 --- src/app/boot/app_controller.nim | 4 +- .../saved_addresses/controller.nim | 8 +- .../saved_addresses/io_interface.nim | 4 +- .../wallet_section/saved_addresses/item.nim | 16 +- .../wallet_section/saved_addresses/model.nim | 22 ++ .../wallet_section/saved_addresses/module.nim | 10 +- .../wallet_section/saved_addresses/view.nim | 14 +- src/app_service/service/saved_address/dto.nim | 15 +- .../service/saved_address/service.nim | 27 +- src/backend/backend.nim | 6 +- ui/app/AppLayouts/Wallet/WalletUtils.qml | 36 ++ .../Wallet/controls/NetworkFilter.qml | 2 +- .../controls/SavedAddressesDelegate.qml | 109 ++++-- .../controls/StatusNetworkListItemTag.qml | 96 ++++++ .../Wallet/controls/StatusNetworkSelector.qml | 218 ++++++++++++ .../popups/AddEditSavedAddressPopup.qml | 312 ++++++++++++++++-- .../Wallet/popups/NetworkSelectPopup.qml | 12 +- ui/app/AppLayouts/Wallet/qmldir | 1 + ui/app/AppLayouts/Wallet/stores/RootStore.qml | 40 ++- .../Wallet/views/SavedAddressesView.qml | 53 ++- .../Wallet/views/TransactionDetailView.qml | 16 +- ui/imports/shared/popups/SendModal.qml | 1 + ui/imports/shared/stores/RootStore.qml | 16 +- ui/imports/utils/Utils.qml | 31 +- 24 files changed, 942 insertions(+), 127 deletions(-) create mode 100644 ui/app/AppLayouts/Wallet/WalletUtils.qml create mode 100644 ui/app/AppLayouts/Wallet/controls/StatusNetworkListItemTag.qml create mode 100644 ui/app/AppLayouts/Wallet/controls/StatusNetworkSelector.qml diff --git a/src/app/boot/app_controller.nim b/src/app/boot/app_controller.nim index c50c82e1dc..d1bddafee4 100644 --- a/src/app/boot/app_controller.nim +++ b/src/app/boot/app_controller.nim @@ -204,7 +204,7 @@ proc newAppController*(statusFoundation: StatusFoundation): AppController = # result.mnemonicService = mnemonic_service.newService() result.privacyService = privacy_service.newService(statusFoundation.events, result.settingsService, result.accountsService) - result.savedAddressService = saved_address_service.newService(statusFoundation.events, result.networkService) + result.savedAddressService = saved_address_service.newService(statusFoundation.events, result.networkService, result.settingsService) result.devicesService = devices_service.newService(statusFoundation.events, statusFoundation.threadpool, result.settingsService) result.mailserversService = mailservers_service.newService(statusFoundation.events, statusFoundation.threadpool, result.settingsService, result.nodeConfigurationService, statusFoundation.fleetConfiguration) @@ -541,4 +541,4 @@ proc addToKeycardUidPairsToCheckForAChangeAfterLogin*(self: AppController, oldKe self.changedKeycardUids.add((oldKcUid: oldKeycardUid, newKcUid: newKeycardUid)) proc removeAllKeycardUidPairsForCheckingForAChangeAfterLogin*(self: AppController) = - self.changedKeycardUids = @[] \ No newline at end of file + self.changedKeycardUids = @[] diff --git a/src/app/modules/main/wallet_section/saved_addresses/controller.nim b/src/app/modules/main/wallet_section/saved_addresses/controller.nim index 046a15375a..98d0ae420c 100644 --- a/src/app/modules/main/wallet_section/saved_addresses/controller.nim +++ b/src/app/modules/main/wallet_section/saved_addresses/controller.nim @@ -28,8 +28,8 @@ proc init*(self: Controller) = proc getSavedAddresses*(self: Controller): seq[saved_address_service.SavedAddressDto] = return self.savedAddressService.getSavedAddresses() -proc createOrUpdateSavedAddress*(self: Controller, name: string, address: string, favourite: bool): string = - return self.savedAddressService.createOrUpdateSavedAddress(name, address, favourite) +proc createOrUpdateSavedAddress*(self: Controller, name: string, address: string, favourite: bool, chainShortNames: string, ens: string): string = + return self.savedAddressService.createOrUpdateSavedAddress(name, address, favourite, chainShortNames, ens) -proc deleteSavedAddress*(self: Controller, address: string): string = - return self.savedAddressService.deleteSavedAddress(address) +proc deleteSavedAddress*(self: Controller, address: string, ens: string): string = + return self.savedAddressService.deleteSavedAddress(address, ens) diff --git a/src/app/modules/main/wallet_section/saved_addresses/io_interface.nim b/src/app/modules/main/wallet_section/saved_addresses/io_interface.nim index 9c4e5d7285..549776df2c 100644 --- a/src/app/modules/main/wallet_section/saved_addresses/io_interface.nim +++ b/src/app/modules/main/wallet_section/saved_addresses/io_interface.nim @@ -17,10 +17,10 @@ method viewDidLoad*(self: AccessInterface) {.base.} = method loadSavedAddresses*(self: AccessInterface) {.base.} = raise newException(ValueError, "No implementation available") -method createOrUpdateSavedAddress*(self: AccessInterface, name: string, address: string, favourite: bool): string {.base.} = +method createOrUpdateSavedAddress*(self: AccessInterface, name: string, address: string, favourite: bool, chainShortNames: string, ens: string): string {.base.} = raise newException(ValueError, "No implementation available") -method deleteSavedAddress*(self: AccessInterface, address: string): string {.base.} = +method deleteSavedAddress*(self: AccessInterface, address: string, ens: string): string {.base.} = raise newException(ValueError, "No implementation available") type diff --git a/src/app/modules/main/wallet_section/saved_addresses/item.nim b/src/app/modules/main/wallet_section/saved_addresses/item.nim index 5e14bbad37..c7c62c0a76 100644 --- a/src/app/modules/main/wallet_section/saved_addresses/item.nim +++ b/src/app/modules/main/wallet_section/saved_addresses/item.nim @@ -6,17 +6,23 @@ type address: string ens: string favourite: bool + chainShortNames: string + isTest: bool proc initItem*( name: string, address: string, favourite: bool, - ens: string + ens: string, + chainShortNames: string, + isTest: bool ): Item = result.name = name result.address = address result.favourite = favourite result.ens = ens + result.chainShortNames = chainShortNames + result.isTest = isTest proc `$`*(self: Item): string = result = fmt"""AllTokensItem( @@ -24,6 +30,8 @@ proc `$`*(self: Item): string = address: {self.address}, favourite: {self.favourite}, ens: {self.ens}, + chainShortNames: {self.chainShortNames}, + isTest: {self.isTest}, ]""" proc getName*(self: Item): string = @@ -37,3 +45,9 @@ proc getAddress*(self: Item): string = proc getFavourite*(self: Item): bool = return self.favourite + +proc getChainShortNames*(self: Item): string = + return self.chainShortNames + +proc getIsTest*(self: Item): bool = + return self.isTest diff --git a/src/app/modules/main/wallet_section/saved_addresses/model.nim b/src/app/modules/main/wallet_section/saved_addresses/model.nim index 8e012a5aec..0da2772eaa 100644 --- a/src/app/modules/main/wallet_section/saved_addresses/model.nim +++ b/src/app/modules/main/wallet_section/saved_addresses/model.nim @@ -8,6 +8,8 @@ type Address Favourite Ens + ChainShortNames + IsTest QtObject: type @@ -47,6 +49,8 @@ QtObject: ModelRole.Address.int:"address", ModelRole.Favourite.int:"favourite", ModelRole.Ens.int:"ens", + ModelRole.ChainShortNames.int:"chainShortNames", + ModelRole.IsTest.int:"isTest", }.toTable method data(self: Model, index: QModelIndex, role: int): QVariant = @@ -68,6 +72,10 @@ QtObject: result = newQVariant(item.getFavourite()) of ModelRole.Ens: result = newQVariant(item.getEns()) + of ModelRole.ChainShortNames: + result = newQVariant(item.getChainShortNames()) + of ModelRole.IsTest: + result = newQVariant(item.getIsTest()) proc rowData(self: Model, index: int, column: string): string {.slot.} = if (index >= self.items.len): @@ -78,6 +86,8 @@ QtObject: of "address": result = $item.getAddress() of "favourite": result = $item.getFavourite() of "ens": result = $item.getEns() + of "chainShortNames": result = $item.getChainShortNames() + of "isTest": result = $item.getIsTest() proc setItems*(self: Model, items: seq[Item]) = self.beginResetModel() @@ -90,3 +100,15 @@ QtObject: if(item.getAddress() == address): return item.getName() return "" + + proc getChainShortNamesForAddress*(self: Model, address: string): string = + for item in self.items: + if(item.getAddress() == address): + return item.getChainShortNames() + return "" + + proc getEnsForAddress*(self: Model, address: string): string = + for item in self.items: + if(item.getAddress() == address): + return item.getEns() + return "" diff --git a/src/app/modules/main/wallet_section/saved_addresses/module.nim b/src/app/modules/main/wallet_section/saved_addresses/module.nim index 9f09e812ae..614a244fd3 100644 --- a/src/app/modules/main/wallet_section/saved_addresses/module.nim +++ b/src/app/modules/main/wallet_section/saved_addresses/module.nim @@ -38,6 +38,8 @@ method loadSavedAddresses*(self: Module) = s.address, s.favourite, s.ens, + s.chainShortNames, + s.isTest, )) ) @@ -55,8 +57,8 @@ method viewDidLoad*(self: Module) = self.moduleLoaded = true self.delegate.savedAddressesModuleDidLoad() -method createOrUpdateSavedAddress*(self: Module, name: string, address: string, favourite: bool): string = - return self.controller.createOrUpdateSavedAddress(name, address, favourite) +method createOrUpdateSavedAddress*(self: Module, name: string, address: string, favourite: bool, chainShortNames: string, ens: string): string = + return self.controller.createOrUpdateSavedAddress(name, address, favourite, chainShortNames, ens) -method deleteSavedAddress*(self: Module, address: string): string = - return self.controller.deleteSavedAddress(address) +method deleteSavedAddress*(self: Module, address: string, ens: string): string = + return self.controller.deleteSavedAddress(address, ens) diff --git a/src/app/modules/main/wallet_section/saved_addresses/view.nim b/src/app/modules/main/wallet_section/saved_addresses/view.nim index 767b86a81e..34783ec456 100644 --- a/src/app/modules/main/wallet_section/saved_addresses/view.nim +++ b/src/app/modules/main/wallet_section/saved_addresses/view.nim @@ -37,11 +37,17 @@ QtObject: proc setItems*(self: View, items: seq[Item]) = self.model.setItems(items) - proc createOrUpdateSavedAddress*(self: View, name: string, address: string, favourite: bool): string {.slot.} = - return self.delegate.createOrUpdateSavedAddress(name, address, favourite) + proc createOrUpdateSavedAddress*(self: View, name: string, address: string, favourite: bool, chainShortNames: string, ens: string): string {.slot.} = + return self.delegate.createOrUpdateSavedAddress(name, address, favourite, chainShortNames, ens) - proc deleteSavedAddress*(self: View, address: string): string {.slot.} = - return self.delegate.deleteSavedAddress(address) + proc deleteSavedAddress*(self: View, address: string, ens: string): string {.slot.} = + return self.delegate.deleteSavedAddress(address, ens) proc getNameByAddress*(self: View, address: string): string {.slot.} = return self.model.getNameByAddress(address) + + proc getChainShortNamesForAddress*(self: View, address: string): string {.slot.} = + return self.model.getChainShortNamesForAddress(address) + + proc getEnsForAddress*(self: View, address: string): string {.slot.} = + return self.model.getEnsForAddress(address) diff --git a/src/app_service/service/saved_address/dto.nim b/src/app_service/service/saved_address/dto.nim index 8d3ca6d71c..c16dfd07fa 100644 --- a/src/app_service/service/saved_address/dto.nim +++ b/src/app_service/service/saved_address/dto.nim @@ -8,20 +8,31 @@ type address*: string ens*: string favourite*: bool + chainShortNames*: string + isTest*: bool proc newSavedAddressDto*( name: string, address: string, - favourite: bool + ens: string, + favourite: bool, + chainShortNames: string, + isTest: bool ): SavedAddressDto = return SavedAddressDto( name: name, address: address, - favourite: favourite + ens: ens, + favourite: favourite, + chainShortNames: chainShortNames, + isTest: isTest ) proc toSavedAddressDto*(jsonObj: JsonNode): SavedAddressDto = result = SavedAddressDto() discard jsonObj.getProp("name", result.name) discard jsonObj.getProp("address", result.address) + discard jsonObj.getProp("ens", result.ens) discard jsonObj.getProp("favourite", result.favourite) + discard jsonObj.getProp("chainShortNames", result.chainShortNames) + discard jsonObj.getProp("isTest", result.isTest) diff --git a/src/app_service/service/saved_address/service.nim b/src/app_service/service/saved_address/service.nim index 45d66b96f9..891fb46c51 100644 --- a/src/app_service/service/saved_address/service.nim +++ b/src/app_service/service/saved_address/service.nim @@ -6,6 +6,7 @@ import ../../../app/core/eventemitter import ../../../backend/backend import ../../../app/core/[main] import ../network/service as network_service +import ../settings/service as settings_service export dto @@ -20,14 +21,17 @@ type events: EventEmitter savedAddresses: seq[SavedAddressDto] networkService: network_service.Service + settingsService: settings_service.Service proc delete*(self: Service) = discard -proc newService*(events: EventEmitter, networkService: network_service.Service): Service = +proc newService*(events: EventEmitter, networkService: network_service.Service, + settingsService: settings_service.Service): Service = result = Service() result.events = events result.networkService = networkService + result.settingsService = settingsService proc fetchAddresses(self: Service) = try: @@ -39,11 +43,12 @@ proc fetchAddresses(self: Service) = ) let chainId = self.networkService.getNetworkForEns().chainId for savedAddress in self.savedAddresses: - try: - let nameResponse = backend.getName(chainId, savedAddress.address) - savedAddress.ens = nameResponse.result.getStr - except: - continue + if savedAddress.ens != "": + try: + let nameResponse = backend.getName(chainId, savedAddress.address) + savedAddress.ens = nameResponse.result.getStr + except: + continue except Exception as e: error "error: ", procName="fetchAddress", errName = e.name, errDesription = e.msg @@ -64,9 +69,10 @@ proc init*(self: Service) = proc getSavedAddresses*(self: Service): seq[SavedAddressDto] = return self.savedAddresses -proc createOrUpdateSavedAddress*(self: Service, name: string, address: string, favourite: bool): string = +proc createOrUpdateSavedAddress*(self: Service, name: string, address: string, favourite: bool, chainShortNames: string, ens: string): string = try: - discard backend.upsertSavedAddress(backend.SavedAddress(name: name, address: address, favourite: favourite)) + let isTestAddress = self.settingsService.areTestNetworksEnabled() + discard backend.upsertSavedAddress(backend.SavedAddress(name: name, address: address, favourite: favourite, chainShortNames: chainShortNames, ens: ens, isTest: isTestAddress)) self.updateAddresses() return "" except Exception as e: @@ -74,9 +80,10 @@ proc createOrUpdateSavedAddress*(self: Service, name: string, address: string, f error "error: ", errDesription return errDesription -proc deleteSavedAddress*(self: Service, address: string): string = +proc deleteSavedAddress*(self: Service, address: string, ens: string): string = try: - var response = backend.deleteSavedAddress(0, address) + let isTestAddress = self.settingsService.areTestNetworksEnabled() + var response = backend.deleteSavedAddress(address, ens, isTestAddress) if not response.error.isNil: raise newException(Exception, response.error.message) diff --git a/src/backend/backend.nim b/src/backend/backend.nim index ff402882d7..eb1ad62551 100644 --- a/src/backend/backend.nim +++ b/src/backend/backend.nim @@ -29,6 +29,9 @@ type name* {.serializedFieldName("name").}: string address* {.serializedFieldName("address").}: string favourite* {.serializedFieldName("favourite").}: bool + chainShortNames* {.serializedFieldName("chainShortNames").}: string + ens* {.serializedFieldName("ens").}: string + isTest* {.serializedFieldName("isTest").}: bool Network* = ref object of RootObj chainId* {.serializedFieldName("chainId").}: int @@ -82,8 +85,9 @@ rpc(upsertSavedAddress, "wakuext"): savedAddress: SavedAddress rpc(deleteSavedAddress, "wakuext"): - chainId: int address: string + ens: string + isTest: bool rpc(getSavedAddresses, "wallet"): discard diff --git a/ui/app/AppLayouts/Wallet/WalletUtils.qml b/ui/app/AppLayouts/Wallet/WalletUtils.qml new file mode 100644 index 0000000000..c6a94a05ed --- /dev/null +++ b/ui/app/AppLayouts/Wallet/WalletUtils.qml @@ -0,0 +1,36 @@ +pragma Singleton + +import QtQuick 2.14 + +import utils 1.0 +import StatusQ.Core.Theme 0.1 + +import "stores" as WalletStores + +QtObject { + function colorizedChainPrefix(prefix) { + if (!prefix) + return "" + + const prefixes = prefix.split(":").filter(Boolean) + let prefixStr = "" + const lastPrefixEndsWithColumn = prefix.endsWith(":") + const defaultColor = Theme.palette.baseColor1 + + for (let i in prefixes) { + const pref = prefixes[i] + let col = WalletStores.RootStore.colorForChainShortName(pref) + if (!col) + col = defaultColor + + prefixStr += Utils.richColorText(pref, col) + // Avoid adding ":" if it was not there for the last prefix, + // because when user manually edits the address, it breaks editing + if (!(i == (prefixes.length - 1) && !lastPrefixEndsWithColumn)) { + prefixStr += Utils.richColorText(":", Theme.palette.baseColor1) + } + } + + return prefixStr + } +} diff --git a/ui/app/AppLayouts/Wallet/controls/NetworkFilter.qml b/ui/app/AppLayouts/Wallet/controls/NetworkFilter.qml index 888fc096f7..70bbd3e20d 100644 --- a/ui/app/AppLayouts/Wallet/controls/NetworkFilter.qml +++ b/ui/app/AppLayouts/Wallet/controls/NetworkFilter.qml @@ -99,7 +99,7 @@ Item { multiSelection: root.multiSelection onToggleNetwork: { - store.toggleNetwork(chainId) + store.toggleNetwork(network.chainId) } onSingleNetworkSelected: { diff --git a/ui/app/AppLayouts/Wallet/controls/SavedAddressesDelegate.qml b/ui/app/AppLayouts/Wallet/controls/SavedAddressesDelegate.qml index 7d1cdc5069..71c1c087fc 100644 --- a/ui/app/AppLayouts/Wallet/controls/SavedAddressesDelegate.qml +++ b/ui/app/AppLayouts/Wallet/controls/SavedAddressesDelegate.qml @@ -6,13 +6,13 @@ import utils 1.0 import StatusQ.Controls 0.1 import StatusQ.Components 0.1 import StatusQ.Core 0.1 -import StatusQ.Core.Utils 0.1 import StatusQ.Core.Theme 0.1 import StatusQ.Popups 0.1 import shared.controls 1.0 import "../popups" import "../controls" +import ".." StatusListItem { id: root @@ -22,55 +22,64 @@ StatusListItem { property string name property string address property string ens + property string chainShortNames property bool favourite: false - property var saveAddress: function (name, address, favourite) {} - property var deleteSavedAddress: function (address) {} + property var saveAddress: function (name, address, favourite, chainShortNames, ens) {} + property var deleteSavedAddress: function (address, ens) {} - signal openSendModal() + signal openSendModal(string recipient) - implicitWidth: parent.width + implicitWidth: ListView.view.width title: name objectName: name - subTitle: (ens.length > 0 ? ens + " \u2022 " : "") - + Utils.elideText(address, 6, 4) - color: "transparent" + subTitle: { + if (ens.length > 0) + return ens + else + return WalletUtils.colorizedChainPrefix(chainShortNames) + address + } border.color: Theme.palette.baseColor5 - titleTextIcon: root.favourite ? "star-icon" : "" + asset.name: root.favourite ? "star-icon" : "favourite" + asset.color: root.favourite ? Theme.palette.pinColor1 : (showButtons ? Theme.palette.directColor1 : Theme.palette.baseColor1) // star icon color default + asset.hoverColor: root.favourite ? "transparent": Theme.palette.directColor1 // star icon color on hover + asset.bgColor: statusListItemIcon.hovered ? Theme.palette.primaryColor3 : "transparent" // icon outer background color + asset.bgRadius: 8 + + statusListItemIcon.hoverEnabled: true + + onIconClicked: { + root.saveAddress(root.name, root.address, !root.favourite, root.chainShortNames, root.ens) + } + + statusListItemSubTitle.font.pixelSize: 13 + statusListItemSubTitle.customColor: !enabled ? Theme.palette.baseColor1 : Theme.palette.directColor1 statusListItemComponentsSlot.spacing: 0 property bool showButtons: sensor.containsMouse + QtObject { + id: d + + readonly property string visibleAddress: root.address == Constants.zeroAddress ? root.ens : root.address + } + components: [ StatusRoundButton { icon.color: root.showButtons ? Theme.palette.directColor1 : Theme.palette.baseColor1 - type: StatusRoundButton.Type.Tertiary + type: StatusRoundButton.Type.Quinary + radius: 8 icon.name: "send" - onClicked: openSendModal() - }, - CopyToClipBoardButton { - id: copyButton - type: StatusRoundButton.Type.Tertiary - icon.color: root.showButtons ? Theme.palette.directColor1 : Theme.palette.baseColor1 - textToCopy: root.address - onCopyClicked: root.store.copyToClipboard(textToCopy) - }, - StatusRoundButton { - objectName: "savedAddressView_Delegate_favouriteButton" - icon.color: root.showButtons ? Theme.palette.directColor1 : Theme.palette.baseColor1 - type: StatusRoundButton.Type.Tertiary - icon.name: root.favourite ? "unfavourite" : "favourite" - onClicked: { - root.saveAddress(root.name, root.address, !root.favourite) - } + onClicked: openSendModal(d.visibleAddress) }, StatusRoundButton { objectName: "savedAddressView_Delegate_menuButton" visible: !!root.name icon.color: root.showButtons ? Theme.palette.directColor1 : Theme.palette.baseColor1 - type: StatusRoundButton.Type.Tertiary + type: StatusRoundButton.Type.Quinary + radius: 8 icon.name: "more" onClicked: { - editDeleteMenu.openMenu(root.name, root.address, root.favourite); + editDeleteMenu.openMenu(root.name, root.address, root.favourite, root.chainShortNames, root.ens); } }, StatusRoundButton { @@ -82,7 +91,8 @@ StatusListItem { Global.openPopup(addEditSavedAddress, { addAddress: true, - address: root.address + address: d.visibleAddress, + ens: root.ens }) } } @@ -93,16 +103,22 @@ StatusListItem { property string contactName property string contactAddress property bool storeFavourite - function openMenu(name, address, favourite) { + property string contactChainShortNames + property string contactEns + function openMenu(name, address, favourite, chainShortNames, ens) { contactName = name; contactAddress = address; storeFavourite = favourite; + contactChainShortNames = chainShortNames; + contactEns = ens; popup(); } onClosed: { contactName = ""; contactAddress = ""; storeFavourite = false; + contactChainShortNames = "" + contactEns = "" } StatusAction { text: qsTr("Edit") @@ -114,10 +130,32 @@ StatusListItem { edit: true, address: editDeleteMenu.contactAddress, name: editDeleteMenu.contactName, - favourite: editDeleteMenu.storeFavourite + favourite: editDeleteMenu.storeFavourite, + chainShortNames: editDeleteMenu.contactChainShortNames, + ens: editDeleteMenu.contactEns }) } } + StatusAction { + text: qsTr("Copy") + objectName: "copySavedAddressAction" + assetSettings.name: "copy" + onTriggered: { + if (d.visibleAddress) + store.copyToClipboard(d.visibleAddress) + else + store.copyToClipboard(root.ens) + } + } + StatusMenuSeparator { } + StatusAction { + text: qsTr("View on Etherscan") + objectName: "viewOnEtherscanAction" + assetSettings.name: "external" + onTriggered: { + Global.openLink("https://etherscan.io/address/%1".arg(d.visibleAddress ? d.visibleAddress : root.ens)) + } + } StatusMenuSeparator { } StatusAction { text: qsTr("Delete") @@ -128,6 +166,7 @@ StatusListItem { deleteAddressConfirm.name = editDeleteMenu.contactName; deleteAddressConfirm.address = editDeleteMenu.contactAddress; deleteAddressConfirm.favourite = editDeleteMenu.storeFavourite; + deleteAddressConfirm.ens = editDeleteMenu.contactEns deleteAddressConfirm.open() } } @@ -140,8 +179,9 @@ StatusListItem { anchors.centerIn: parent onClosed: destroy() contactsStore: root.contactsStore + store: root.store onSave: { - root.saveAddress(name, address, favourite) + root.saveAddress(name, address, favourite, chainShortNames, ens) close() } } @@ -150,6 +190,7 @@ StatusListItem { StatusModal { id: deleteAddressConfirm property string address + property string ens property string name property bool favourite anchors.centerIn: parent @@ -177,7 +218,7 @@ StatusListItem { objectName: "confirmDeleteSavedAddress" text: qsTr("Delete") onClicked: { - root.deleteSavedAddress(deleteAddressConfirm.address) + root.deleteSavedAddress(deleteAddressConfirm.address, deleteAddressConfirm.ens) deleteAddressConfirm.close() } } diff --git a/ui/app/AppLayouts/Wallet/controls/StatusNetworkListItemTag.qml b/ui/app/AppLayouts/Wallet/controls/StatusNetworkListItemTag.qml new file mode 100644 index 0000000000..4950226f78 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/controls/StatusNetworkListItemTag.qml @@ -0,0 +1,96 @@ +import QtQuick 2.14 +import QtQuick.Controls 2.14 +import QtQuick.Layouts 1.14 +import StatusQ.Core 0.1 +import StatusQ.Core.Theme 0.1 +import StatusQ.Controls 0.1 +import StatusQ.Components 0.1 + +Control { + id: root + + property alias titleText: titleText + property alias button: button + + property string title: "" + + signal clicked(var mouse) + + property StatusAssetSettings asset: StatusAssetSettings { + height: 20 + width: 20 + rotation: 0 + isLetterIdenticon: false + letterSize: 10 + color: "transparent" + bgWidth: 15 + bgHeight: 15 + bgColor: "transparent" + bgBorderColor: Theme.palette.baseColor2 + bgRadius: 16 + imgIsIdenticon: false + } + + QtObject { + id: d + readonly property int commonMargin: 5 + readonly property int leftMargin: 8 + readonly property int minHeight: 32 + } + + leftPadding: d.leftMargin + spacing: d.commonMargin + implicitHeight: d.minHeight + + background: Rectangle { + color: root.hovered ? Theme.palette.primaryColor3 : asset.bgColor + radius: asset.bgRadius + border.color: asset.bgBorderColor + + MouseArea { + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + anchors.fill: parent + onClicked: root.clicked(mouse) + } + } + + contentItem: RowLayout { + spacing: root.spacing + + StatusSmartIdenticon { + id: iconOrImage + asset: root.asset + name: root.title + active: root.asset.isLetterIdenticon || + !!root.asset.name + } + + StatusBaseText { + id: titleText + + Layout.rightMargin: button.visible ? 0 : d.commonMargin + Layout.fillWidth: true + + color: enabled ? Theme.palette.primaryColor1 : Theme.palette.baseColor1 + text: root.title + font.pixelSize: 15 + font.weight: Font.Medium + elide: Text.ElideRight + } + + StatusRoundButton { + id: button + + Layout.preferredHeight: root.height - d.commonMargin + Layout.preferredWidth: root.height - d.commonMargin + Layout.rightMargin: d.commonMargin + Layout.alignment: Qt.AlignVCenter | Qt.AlignRight + + radius: height / 2 + + type: StatusRoundButton.Tertiary + icon.name: "close" + } + } +} diff --git a/ui/app/AppLayouts/Wallet/controls/StatusNetworkSelector.qml b/ui/app/AppLayouts/Wallet/controls/StatusNetworkSelector.qml new file mode 100644 index 0000000000..0539ab7b74 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/controls/StatusNetworkSelector.qml @@ -0,0 +1,218 @@ +import QtQuick 2.14 +import QtQuick.Layouts 1.14 +import QtQuick.Controls 2.14 as QC + +import StatusQ.Core 0.1 +import StatusQ.Core.Theme 0.1 +import StatusQ.Controls 0.1 +import StatusQ.Core.Utils 0.1 + +import utils 1.0 + +/*! + \qmltype StatusNetworkSelector + \inherits Rectangle + \inqmlmodule StatusQ.Components + \since StatusQ.Components 0.1 + \brief It allows to add items and display them as a tag item with an image and text. It also allows to store and display logical `and` / `or` operators into the list. Inherits \l{https://doc.qt.io/qt-6/qml-qtquick-rectangle.html}{Item}. + + The \c StatusNetworkSelector is populated with a data model. The data model is commonly a JavaScript array or a ListModel object with specific expected roles. + + Example of how the component looks like: + \image status_item_selector.png + + Example of how to use it: + \qml + StatusNetworkSelector { + id: networkSelector + + title: "Network preference" + enabled: addressInput.valid + defaultItemText: "Add networks" + defaultItemImageSource: "add" + + itemsModel: ListModel {} + + addButton.onClicked: { + } + + onItemClicked: { + } + + onItemRightButtonClicked: { + } + } + \endqml + For a list of components available see StatusQ. +*/ +Rectangle { + id: root + + /*! + \qmlproperty string StatusNetworkSelector::title + This property holds the title shown on top of the component. + */ + property string title + /*! + \qmlproperty string StatusNetworkSelector::defaultItemText + This property holds the default item text shown when the list of items is empty. + */ + property string defaultItemText + /*! + \qmlproperty url StatusNetworkSelector::defaultItemImageSource + This property holds the default item icon shown when the list of items is empty. + */ + property string defaultItemImageSource: "" + /*! + \qmlproperty StatusRoundButton StatusNetworkSelector::addButton + This property holds an alias to the `add` button. + */ + readonly property alias addButton: addItemButton + /*! + \qmlproperty ListModel StatusNetworkSelector::itemsModel + This property holds the data that will be populated in the items selector. + + Here an example of the model roles expected: + \qml + itemsModel: ListModel { + ListElement { + text: "Ethereum" + iconUrl: "Network=Ethereum" + } + ListElement { + text: "Optimism" + iconUrl: "Network=Optimism" + } + } + \endqml + */ + property var itemsModel: ListModel { } + /*! + \qmlproperty bool StatusNetworkSelector::useIcons + This property determines if the imageSource role from the model will be handled as + an image or an icon. + */ + property bool useIcons: false + + property StatusAssetSettings asset: StatusAssetSettings { + height: 20 + width: 20 + bgColor: "transparent" + isImage: !root.useIcons + isLetterIdenticon: root.useLetterIdenticons + } + + /*! + \qmlproperty bool StatusNetworkSelector::useLetterIdenticons + This property determines if letter identicons should be used. If set to + true, the model is expected to contain roles "color" and "emoji". + */ + property bool useLetterIdenticons: false + + /*! + \qmlsignal StatusNetworkSelector::itemClicked + This signal is emitted when the item is clicked. + */ + signal itemClicked(var item, int index, var mouse) + + /*! + \qmlsignal StatusNetworkSelector::itemRightButtonClicked + This signal is emitted when the item's right button is clicked. + */ + signal itemRightButtonClicked(var item, int index, var mouse) + + color: "transparent" + + implicitHeight: columnLayout.implicitHeight + implicitWidth: 560 + + property bool rightButtonVisible: false + + /*! + \qmlproperty StatusNetworkListItemTag StatusNetworkSelector::defaultItem + This property holds an alias to the `defaultItem` tag + */ + + property alias defaultItem: defaultListItemTag + + ColumnLayout { + id: columnLayout + + spacing: 8 + + StatusBaseText { + text: root.title + color: Theme.palette.directColor1 + font.pixelSize: 15 + } + + Flow { + id: flow + + Layout.preferredWidth: root.width + Layout.fillWidth: true + + spacing: 6 + + StatusRoundButton { + id: addItemButton + + implicitHeight: 32 + implicitWidth: implicitHeight + height: width + type: StatusRoundButton.Type.Tertiary + border.color: Theme.palette.baseColor2 + icon.name: root.defaultItemImageSource + visible: itemsModel.count > 0 + icon.color: Theme.palette.primaryColor1 + } + + StatusNetworkListItemTag { + id: defaultListItemTag + + visible: !itemsModel || itemsModel.count === 0 + title: root.defaultItemText + button.visible: true + button.icon.name: root.defaultItemImageSource + button.enabled: false + button.icon.disabledColor: titleText.color + button.icon.color: titleText.color + onClicked: { + root.itemClicked(this, 0, mouse) + } + } + + Repeater { + model: itemsModel + + StatusNetworkListItemTag { + id: networkTag + + title: model.chainName + + asset.height: root.asset.height + asset.width: root.asset.width + asset.name: root.useLetterIdenticons ? model.text : Style.svg(model.iconUrl) + asset.isImage: root.asset.isImage + asset.bgColor: root.asset.bgColor + asset.isLetterIdenticon: root.useLetterIdenticons + button.visible: root.rightButtonVisible + titleText.color: Theme.palette.primaryColor1 + button.icon.disabledColor: titleText.color + button.icon.color: titleText.color + hoverEnabled: false + + property var modelRef: model // model is not reachable outside via item.model.someData, so expose it + + onClicked: { + root.itemClicked(this, index, mouse) + } + + button.onClicked: { + root.itemRightButtonClicked(networkTag, index, mouse) + } + } + } + } + } +} diff --git a/ui/app/AppLayouts/Wallet/popups/AddEditSavedAddressPopup.qml b/ui/app/AppLayouts/Wallet/popups/AddEditSavedAddressPopup.qml index c257e9c654..621ec29130 100644 --- a/ui/app/AppLayouts/Wallet/popups/AddEditSavedAddressPopup.qml +++ b/ui/app/AppLayouts/Wallet/popups/AddEditSavedAddressPopup.qml @@ -1,6 +1,7 @@ import QtQuick 2.13 import QtQuick.Controls 2.13 import QtQml.Models 2.14 +import QtQuick.Layouts 1.14 import utils 1.0 import shared.controls 1.0 @@ -11,28 +12,53 @@ import StatusQ.Core.Theme 0.1 import StatusQ.Controls 0.1 import StatusQ.Controls.Validators 0.1 import StatusQ.Popups.Dialog 0.1 +import StatusQ.Components 0.1 +import SortFilterProxyModel 0.2 + +import "../controls" import "../stores" +import ".." StatusDialog { id: root property bool edit: false property bool addAddress: false - property string address + property string address: Constants.zeroAddress // Setting as zero address since we don't have the address yet + property string chainShortNames + property string ens + property alias name: nameInput.text property bool favourite: false property var contactsStore + property var store - signal save(string name, string address) + signal save(string name, string address, string chainShortNames, string ens) QtObject { id: d - property int validationMode: root.edit ? + readonly property int validationMode: root.edit ? StatusInput.ValidationMode.Always : StatusInput.ValidationMode.OnlyWhenDirty - property bool valid: addressInput.isValid && nameInput.valid // TODO: Add network preference and emoji - property bool dirty: nameInput.input.dirty + readonly property bool valid: addressInput.valid && nameInput.valid + property bool chainShortNamesDirty: false + readonly property bool dirty: nameInput.input.dirty || chainShortNamesDirty + + readonly property var chainPrefixRegexPattern: /[^:]+\:?|:/g + readonly property string visibleAddress: root.address == Constants.zeroAddress ? "" : root.address + readonly property bool addressInputIsENS: !visibleAddress + + function getPrefixArrayWithColumns(prefixStr) { + return prefixStr.match(d.chainPrefixRegexPattern) + } + + function resetAddressValues() { + root.ens = "" + root.address = Constants.zeroAddress + root.chainShortNames = "" + allNetworksModelCopy.setEnabledNetworks([]) + } } width: 574 @@ -46,7 +72,10 @@ StatusDialog { onOpened: { if(edit || addAddress) { - addressInput.input.text = root.address + if (root.ens) + addressInput.setPlainText(root.ens) + else + addressInput.setPlainText(root.chainShortNames + d.visibleAddress) } nameInput.input.edit.forceActiveFocus(Qt.MouseFocusReason) } @@ -54,7 +83,7 @@ StatusDialog { Column { width: parent.width height: childrenRect.height - topPadding: Style.current.xlPadding + topPadding: Style.current.bigPadding spacing: Style.current.bigPadding @@ -62,9 +91,7 @@ StatusDialog { id: nameInput implicitWidth: parent.width input.edit.objectName: "savedAddressNameInput" - minimumHeight: 56 - maximumHeight: 56 - placeholderText: qsTr("Enter a name") + placeholderText: qsTr("Address owner") label: qsTr("Name") validators: [ StatusMinLengthValidator { @@ -76,33 +103,226 @@ StatusDialog { errorMessage: qsTr("This is not a valid account name") } ] + input.clearable: true + input.rightPadding: 16 charLimit: 40 validationMode: d.validationMode } - // To-Do use StatusInput within the below component - RecipientSelector { + StatusInput { id: addressInput implicitWidth: parent.width - inputWidth: implicitWidth - accounts: RootStore.accounts - contactsStore: root.contactsStore label: qsTr("Address") - input.textField.objectName: "savedAddressAddressInput" - input.placeholderText: qsTr("Enter ENS Name or Ethereum Address") - labelFont.pixelSize: 15 - labelFont.weight: Font.Normal - input.implicitHeight: 56 - input.textField.anchors.rightMargin: 0 - isSelectorVisible: false - addContactEnabled: false - onSelectedRecipientChanged: { - root.address = selectedRecipient.address + input.edit.objectName: "savedAddressAddressInput" + placeholderText: qsTr("Ethereum Address") + maximumHeight: 66 + input.implicitHeight: Math.min(Math.max(input.edit.contentHeight + topPadding + bottomPadding, minimumHeight), maximumHeight) // setting height instead does not work + enabled: !(root.edit || root.addAddress) + validators: [ + StatusMinLengthValidator { + minLength: 1 + errorMessage: qsTr("Address must not be blank") + }, + StatusValidator { + errorMessage: addressInput.plainText ? qsTr("Please enter a valid address or ENS name.") : "" + validate: function (t) { + return Utils.isValidAddressWithChainPrefix(t) || Utils.isValidEns(t) + ? true : { actual: t } + } + } + ] + validationMode: d.validationMode + + input.edit.textFormat: TextEdit.RichText + input.asset.name: addressInput.valid && !root.edit ? "checkbox" : "" + input.asset.color: enabled ? Theme.palette.primaryColor1 : Theme.palette.baseColor1 + input.rightPadding: 16 + input.leftIcon: false + + multiline: true + + property string plainText: input.edit.getText(0, text.length) + + onTextChanged: { + if (skipTextUpdate) + return + + plainText = input.edit.getText(0, text.length) + + if (input.edit.previousText != plainText) { + let newText = plainText + const prefixAndAddress = Utils.splitToChainPrefixAndAddress(plainText) + + if (!Utils.isLikelyEnsName(plainText)) { + newText = WalletUtils.colorizedChainPrefix(prefixAndAddress.prefix) + + prefixAndAddress.address + } + + setRichText(newText) + + // Reset + if (plainText.length == 0) { + d.resetAddressValues() + return + } + + // Update root values + if (Utils.isLikelyEnsName(plainText)) { + root.ens = plainText + root.address = Constants.zeroAddress + root.chainShortNames = "" + } + else { + root.ens = "" + root.address = prefixAndAddress.address + root.chainShortNames = prefixAndAddress.prefix + + let prefixArrWithColumn = d.getPrefixArrayWithColumns(prefixAndAddress.prefix) + if (!prefixArrWithColumn) + prefixArrWithColumn = [] + + allNetworksModelCopy.setEnabledNetworks(prefixArrWithColumn) + } + } + } + + property bool skipTextUpdate: false + + function setPlainText(newText) { + text = newText + } + + function setRichText(val) { + skipTextUpdate = true + input.edit.previousText = plainText + const curPos = input.cursorPosition + setPlainText(val) + input.cursorPosition = curPos + skipTextUpdate = false + } + + function getUnknownPrefixes(prefixes) { + let unknownPrefixes = prefixes.filter(e => { + for (let i = 0; i < allNetworksModelCopy.count; i++) { + if (e == allNetworksModelCopy.get(i).shortName) + return false + } + return true + }) + + return unknownPrefixes + } + + // Add all chain short names from model, while keeping existing + function syncChainPrefixWithModel(prefix, model) { + let prefixes = prefix.split(":").filter(Boolean) + let prefixStr = "" + + // Keep unknown prefixes from user input, the rest must be taken + // from the model + for (let i = 0; i < model.count; i++) { + const item = model.get(i) + prefixStr += item.shortName + ":" + // Remove all added prefixes from initial array + prefixes = prefixes.filter(e => e !== item.shortName) + } + + const unknownPrefixes = getUnknownPrefixes(prefixes) + if (unknownPrefixes.length > 0) { + prefixStr += unknownPrefixes.join(":") + ":" + } + + return prefixStr } - readOnly: root.edit || root.addAddress - wrongInputValidationError: qsTr("Please enter a valid ENS name OR Ethereum Address") - ownAddressError: qsTr("Can't add yourself as a saved address") } + + StatusNetworkSelector { + id: networkSelector + + title: "Network preference" + enabled: addressInput.valid && !d.addressInputIsENS + defaultItemText: "Add networks" + defaultItemImageSource: "add" + rightButtonVisible: true + + property bool modelUpdateBlocked: false + + function blockModelUpdate(value) { + modelUpdateBlocked = value + } + + itemsModel: SortFilterProxyModel { + sourceModel: allNetworksModelCopy + filters: ValueFilter { + roleName: "isEnabled" + value: true + } + + onCountChanged: { + if (!networkSelector.modelUpdateBlocked) { + // Initially source model is empty, filter proxy is also empty, but does + // extra work and mistakenly overwrites root.chainShortNames property + if (sourceModel.count != 0) { + const prefixAndAddress = Utils.splitToChainPrefixAndAddress(addressInput.plainText) + const syncedPrefix = addressInput.syncChainPrefixWithModel(prefixAndAddress.prefix, this) + root.chainShortNames = syncedPrefix + addressInput.setPlainText(syncedPrefix + prefixAndAddress.address) + } + } + } + } + + addButton.highlighted: networkSelectPopup.visible + addButton.onClicked: { + networkSelectPopup.openAtPosition(addButton.x, networkSelector.y + addButton.height + Style.current.xlPadding) + } + + onItemClicked: function (item, index, mouse) { + // Append first item + if (index === 0 && defaultItem.visible) + networkSelectPopup.openAtPosition(defaultItem.x, networkSelector.y + defaultItem.height + Style.current.xlPadding) + } + + onItemRightButtonClicked: function (item, index, mouse) { + item.modelRef.isEnabled = !item.modelRef.isEnabled + d.chainShortNamesDirty = true + } + } + } + + NetworkSelectPopup { + id: networkSelectPopup + + layer1Networks: SortFilterProxyModel { + sourceModel: allNetworksModelCopy + filters: ValueFilter { + roleName: "layer" + value: 1 + } + } + layer2Networks: SortFilterProxyModel { + sourceModel: allNetworksModelCopy + filters: ValueFilter { + roleName: "layer" + value: 2 + } + } + + onToggleNetwork: { + network.isEnabled = !network.isEnabled + d.chainShortNamesDirty = true + } + + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + function openAtPosition(xPos, yPos) { + x = xPos + y = yPos + open() + } + + modal: true + dim: false } footer: StatusDialogFooter { @@ -110,9 +330,43 @@ StatusDialog { StatusButton { text: root.edit ? qsTr("Save") : qsTr("Add address") enabled: d.valid && d.dirty - onClicked: root.save(name, address) + onClicked: root.save(name, address, chainShortNames, ens) objectName: "addSavedAddress" } } } + + ListModel { + id: allNetworksModelCopy + + function setEnabledNetworks(prefixArr) { + networkSelector.blockModelUpdate(true) + for (let i = 0; i < count; i++) { + // Add only those chainShortNames to the model, that have column ":" at the end, making it a valid chain prefix + setProperty(i, "isEnabled", prefixArr.includes(get(i).shortName + ":")) + } + networkSelector.blockModelUpdate(false) + } + + function init(model, address) { + const prefixStr = Utils.getChainsPrefix(address) + for (let i = 0; i < model.count; i++) { + const clonedItem = { + layer: model.rowData(i, "layer"), + chainId: model.rowData(i, "chainId"), + chainColor: model.rowData(i, "chainColor"), + chainName: model.rowData(i, "chainName"), + shortName: model.rowData(i, "shortName"), + iconUrl: model.rowData(i, "iconUrl"), + isEnabled: Boolean(prefixStr.length > 0 && prefixStr.includes(shortName)) + } + + append(clonedItem) + } + } + } + + Component.onCompleted: { + allNetworksModelCopy.init(store.allNetworks, root.address) + } } diff --git a/ui/app/AppLayouts/Wallet/popups/NetworkSelectPopup.qml b/ui/app/AppLayouts/Wallet/popups/NetworkSelectPopup.qml index f0ac4eee7f..7d942f2d53 100644 --- a/ui/app/AppLayouts/Wallet/popups/NetworkSelectPopup.qml +++ b/ui/app/AppLayouts/Wallet/popups/NetworkSelectPopup.qml @@ -30,7 +30,7 @@ Popup { property bool multiSelection: true - signal toggleNetwork(int chainId) + signal toggleNetwork(var network) signal singleNetworkSelected(int chainId, string chainName, string iconUrl) background: Rectangle { @@ -114,7 +114,7 @@ Popup { asset.name: Style.svg(model.iconUrl) onClicked: { if(root.multiSelection) - checkBox.toggle() + checkBox.toggled() else radioButton.toggle() } @@ -124,10 +124,10 @@ Popup { visible: root.multiSelection checked: root.useNetworksExtraStoreProxy ? model.isActive : model.isEnabled onToggled: { - if(root.useNetworksExtraStoreProxy && model.isActive !== checked) { - model.isActive = checked - } else if (model.isEnabled !== checked) { - root.toggleNetwork(model.chainId) + if (root.useNetworksExtraStoreProxy) { + model.isActive = !model.isActive + } else { + root.toggleNetwork(model) } } }, diff --git a/ui/app/AppLayouts/Wallet/qmldir b/ui/app/AppLayouts/Wallet/qmldir index 3f86f08fec..3683514613 100644 --- a/ui/app/AppLayouts/Wallet/qmldir +++ b/ui/app/AppLayouts/Wallet/qmldir @@ -2,3 +2,4 @@ LeftTabView 1.0 LeftTabView.qml WalletHeader 1.0 WalletHeader.qml CollectiblesView 1.0 CollectiblesView.qml WalletLayout 1.0 WalletLayout.qml +singleton WalletUtils 1.0 WalletUtils.qml diff --git a/ui/app/AppLayouts/Wallet/stores/RootStore.qml b/ui/app/AppLayouts/Wallet/stores/RootStore.qml index 18fd34ac93..2f8d91e265 100644 --- a/ui/app/AppLayouts/Wallet/stores/RootStore.qml +++ b/ui/app/AppLayouts/Wallet/stores/RootStore.qml @@ -3,7 +3,9 @@ pragma Singleton import QtQuick 2.13 import utils 1.0 -import "../panels" +import SortFilterProxyModel 0.2 +import StatusQ.Core.Theme 0.1 + QtObject { id: root @@ -30,7 +32,30 @@ QtObject { property var flatCollectibles: walletSectionCollectibles.flatModel property var currentCollectible: walletSectionCurrentCollectible - property var savedAddresses: walletSectionSavedAddresses.model + property var savedAddresses: SortFilterProxyModel { + sourceModel: walletSectionSavedAddresses.model + filters: [ + ValueFilter { + roleName: "isTest" + value: networksModule.areTestNetworksEnabled + } + ] + } + + property QtObject _d: QtObject { + id: d + property var chainColors: ({}) + + function initChainColors(model) { + for (let i = 0; i < model.count; i++) { + chainColors[model.rowData(i, "shortName")] = model.rowData(i, "chainColor") + } + } + } + + function colorForChainShortName(chainShortName) { + return d.chainColors[chainShortName] + } // Used for new wallet account generation property var generatedAccountsViewModel: walletSectionAccounts.generatedAccounts @@ -41,6 +66,9 @@ QtObject { property var testNetworks: networksModule.test property var enabledNetworks: networksModule.enabled property var allNetworks: networksModule.all + onAllNetworksChanged: { + d.initChainColors(allNetworks) + } property var layer1NetworksProxy: networksModule.layer1Proxy property var layer2NetworksProxy: networksModule.layer2Proxy @@ -170,12 +198,12 @@ QtObject { walletSectionCurrentCollectible.update(slug, id) } - function createOrUpdateSavedAddress(name, address, favourite) { - return walletSectionSavedAddresses.createOrUpdateSavedAddress(name, address, favourite) + function createOrUpdateSavedAddress(name, address, favourite, chainShortNames, ens) { + return walletSectionSavedAddresses.createOrUpdateSavedAddress(name, address, favourite, chainShortNames, ens) } - function deleteSavedAddress(address) { - return walletSectionSavedAddresses.deleteSavedAddress(address) + function deleteSavedAddress(address, ens) { + return walletSectionSavedAddresses.deleteSavedAddress(address, ens) } function toggleNetwork(chainId) { diff --git a/ui/app/AppLayouts/Wallet/views/SavedAddressesView.qml b/ui/app/AppLayouts/Wallet/views/SavedAddressesView.qml index 6b88bdd93c..2cc96c62e9 100644 --- a/ui/app/AppLayouts/Wallet/views/SavedAddressesView.qml +++ b/ui/app/AppLayouts/Wallet/views/SavedAddressesView.qml @@ -6,7 +6,6 @@ import utils 1.0 import StatusQ.Controls 0.1 import StatusQ.Components 0.1 import StatusQ.Core 0.1 -import StatusQ.Core.Utils 0.1 import StatusQ.Core.Theme 0.1 import StatusQ.Popups 0.1 import shared.controls 1.0 @@ -27,16 +26,21 @@ Item { id: _internal property bool loading: false property string error: "" - function saveAddress(name, address, favourite) { + property var lastCreatedAddress // used to display animation for the newly saved address + function saveAddress(name, address, favourite, chainShortNames, ens) { loading = true - error = RootStore.createOrUpdateSavedAddress(name, address, favourite) + error = RootStore.createOrUpdateSavedAddress(name, address, favourite, chainShortNames, ens) loading = false } - function deleteSavedAddress(address) { + function deleteSavedAddress(address, ens) { loading = true - error = RootStore.deleteSavedAddress(address) + error = RootStore.deleteSavedAddress(address, ens) loading = false } + + function resetLastCreatedAddress() { + lastCreatedAddress = undefined + } } Item { @@ -105,21 +109,46 @@ Item { spacing: 5 model: RootStore.savedAddresses delegate: SavedAddressesDelegate { + id: savedAddressDelegate + objectName: "savedAddressView_Delegate_" + name name: model.name address: model.address + chainShortNames: model.chainShortNames ens: model.ens favourite: model.favourite store: RootStore contactsStore: root.contactsStore - onOpenSendModal: root.sendModal.open(address); - saveAddress: function(name, address, favourite) { - _internal.saveAddress(name, address, favourite) + onOpenSendModal: root.sendModal.open(recipient); + saveAddress: function(name, address, favourite, chainShortNames, ens) { + _internal.saveAddress(name, address, favourite, chainShortNames, ens) } - deleteSavedAddress: function(address) { - _internal.deleteSavedAddress(address) + deleteSavedAddress: function(address, ens) { + _internal.deleteSavedAddress(address, ens) } + + states: [ + State { + name: "highlighted" + when: _internal.lastCreatedAddress ? (_internal.lastCreatedAddress.address.toLowerCase() === address.toLowerCase() && + _internal.lastCreatedAddress.ens === ens) : false + PropertyChanges { target: savedAddressDelegate; color: Theme.palette.baseColor2 } + StateChangeScript { + script: Qt.callLater(_internal.resetLastCreatedAddress) + } + } + ] + + transitions: [ + Transition { + from: "highlighted" + ColorAnimation { + target: savedAddressDelegate + duration: 3000 + } + } + ] } } @@ -130,8 +159,10 @@ Item { anchors.centerIn: parent onClosed: destroy() contactsStore: root.contactsStore + store: RootStore onSave: { - _internal.saveAddress(name, address, favourite) + _internal.lastCreatedAddress = { address: address, ens: ens } + _internal.saveAddress(name, address, favourite, chainShortNames, ens) close() } } diff --git a/ui/app/AppLayouts/Wallet/views/TransactionDetailView.qml b/ui/app/AppLayouts/Wallet/views/TransactionDetailView.qml index 410f7fcbb8..94fffc13c8 100644 --- a/ui/app/AppLayouts/Wallet/views/TransactionDetailView.qml +++ b/ui/app/AppLayouts/Wallet/views/TransactionDetailView.qml @@ -14,6 +14,8 @@ import utils 1.0 import shared.stores 1.0 import "../controls" +import "../stores" as WalletStores +import ".." Item { id: root @@ -31,6 +33,8 @@ Item { readonly property string savedAddressNameFrom: root.isTransactionValid ? d.getNameForSavedWalletAddress(transaction.from): "" readonly property string from: root.isTransactionValid ? !!savedAddressNameFrom ? savedAddressNameFrom : Utils.compactAddress(transaction.from, 4): "" readonly property string to: root.isTransactionValid ? !!savedAddressNameTo ? savedAddressNameTo : Utils.compactAddress(transaction.to, 4): "" + readonly property string savedAddressEns: RootStore.getEnsForSavedWalletAddress(isIncoming ? transaction.from : transaction.to) + readonly property string savedAddressChains: RootStore.getChainShortNamesForSavedWalletAddress(isIncoming ? transaction.from : transaction.to) function getNameForSavedWalletAddress(address) { return RootStore.getNameForSavedWalletAddress(address) @@ -81,16 +85,18 @@ Item { name: d.isIncoming ? d.savedAddressNameFrom : d.savedAddressNameTo address: root.isTransactionValid ? d.isIncoming ? transaction.from : transaction.to : "" + ens: d.savedAddressEns + chainShortNames: d.savedAddressChains title: d.isIncoming ? d.from : d.to subTitle: root.isTransactionValid ? d.isIncoming ? !!d.savedAddressNameFrom ? Utils.compactAddress(transaction.from, 4) : "" : !!d.savedAddressNameTo ? Utils.compactAddress(transaction.to, 4) : "": "" - store: RootStore + store: WalletStores.RootStore contactsStore: root.contactsStore onOpenSendModal: root.sendModal.open(address); - saveAddress: function(name, address, favourite) { - RootStore.createOrUpdateSavedAddress(name, address, favourite) + saveAddress: function(name, address, favourite, chainShortNames, ens) { + RootStore.createOrUpdateSavedAddress(name, address, favourite, chainShortNames, ens) } - deleteSavedAddress: function(address) { - RootStore.deleteSavedAddress(address) + deleteSavedAddress: function(address, ens) { + RootStore.deleteSavedAddress(address, ens) } } diff --git a/ui/imports/shared/popups/SendModal.qml b/ui/imports/shared/popups/SendModal.qml index 7d37f39265..3dba53a271 100644 --- a/ui/imports/shared/popups/SendModal.qml +++ b/ui/imports/shared/popups/SendModal.qml @@ -375,6 +375,7 @@ StatusDialog { label: qsTr("To") placeholderText: qsTr("Enter an ENS name or address") + text: popup.addressText input.background.color: Theme.palette.indirectColor1 input.background.border.width: 0 input.implicitHeight: 56 diff --git a/ui/imports/shared/stores/RootStore.qml b/ui/imports/shared/stores/RootStore.qml index 841d8ebf30..d47e240c30 100644 --- a/ui/imports/shared/stores/RootStore.qml +++ b/ui/imports/shared/stores/RootStore.qml @@ -193,12 +193,20 @@ QtObject { return walletSectionSavedAddresses.getNameByAddress(address) } - function createOrUpdateSavedAddress(name, address, favourite) { - return walletSectionSavedAddresses.createOrUpdateSavedAddress(name, address, favourite) + function getChainShortNamesForSavedWalletAddress(address) { + return walletSectionSavedAddresses.getChainShortNamesForAddress(address) } - function deleteSavedAddress(address) { - return walletSectionSavedAddresses.deleteSavedAddress(address) + function getEnsForSavedWalletAddress(address) { + return walletSectionSavedAddresses.getEnsForAddress(address) + } + + function createOrUpdateSavedAddress(name, address, favourite, chainShortNames, ens) { + return walletSectionSavedAddresses.createOrUpdateSavedAddress(name, address, favourite, chainShortNames, ens) + } + + function deleteSavedAddress(addresse, ens) { + return walletSectionSavedAddresses.deleteSavedAddress(address, ens) } function getLatestBlockNumber() { diff --git a/ui/imports/utils/Utils.qml b/ui/imports/utils/Utils.qml index 153dcb6f80..a17a4f8d47 100644 --- a/ui/imports/utils/Utils.qml +++ b/ui/imports/utils/Utils.qml @@ -38,6 +38,35 @@ QtObject { return startsWith0x(value) && isHex(value) && value.length === 42 } + function isValidAddressWithChainPrefix(value) { + return value.match(/^(([a-zA-Z]{3,5}:)*)?(0x[a-fA-F0-9]{40})$/) + } + + function getChainsPrefix(address) { + // matchAll is not supported by QML JS engine + return address.match(/([a-zA-Z]{3,5}:)*/)[0].split(':').filter(e => !!e) + } + + function isLikelyEnsName(text) { + return text.startsWith("@") || !isLikelyAddress(text) + } + + function isLikelyAddress(text) { + return text.includes(":") || text.includes('0x') + } + + function richColorText(text, color) { + return "" + text + "" + } + + function splitToChainPrefixAndAddress(input) { + const addressIdx = input.indexOf('0x') + if (addressIdx < 0) + return { prefix: input, address: "" } + + return { prefix: input.substring(0, addressIdx), address: input.substring(addressIdx) } + } + function isPrivateKey(value) { return isHex(value) && ((startsWith0x(value) && value.length === 66) || (!startsWith0x(value) && value.length === 64)) @@ -109,7 +138,7 @@ QtObject { return false } const isEmail = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/.test(inputValue) - const isDomain = /(?:(?:(?[\w\-]*)(?:\.))?(?[\w\-]*))\.(?[\w\-]*)/.test(inputValue) + const isDomain = /\b((?=[a-z0-9-]{1,63}\.)(xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,63}\b/.test(inputValue) return isEmail || isDomain || (inputValue.startsWith("@") && inputValue.length > 1) }