diff --git a/src/app/modules/main/profile_section/wallet/accounts/controller.nim b/src/app/modules/main/profile_section/wallet/accounts/controller.nim index f633ef4a89..70c727df6a 100644 --- a/src/app/modules/main/profile_section/wallet/accounts/controller.nim +++ b/src/app/modules/main/profile_section/wallet/accounts/controller.nim @@ -1,7 +1,6 @@ import io_interface -import ../../../../../../app_service/service/wallet_account/service as wallet_account_service - -import ../../../../shared_modules/keycard_popup/io_interface as keycard_shared_module +import app_service/service/wallet_account/service as wallet_account_service +import app/modules/shared_modules/keycard_popup/io_interface as keycard_shared_module type Controller* = ref object of RootObj @@ -31,6 +30,9 @@ proc updateAccount*(self: Controller, address: string, accountName: string, colo proc updateAccountPosition*(self: Controller, address: string, position: int) = self.walletAccountService.updateWalletAccountPosition(address, position) +proc renameKeypair*(self: Controller, keyUid: string, name: string) = + self.walletAccountService.updateKeypairName(keyUid, name) + proc deleteAccount*(self: Controller, address: string) = self.walletAccountService.deleteAccount(address) diff --git a/src/app/modules/main/profile_section/wallet/accounts/io_interface.nim b/src/app/modules/main/profile_section/wallet/accounts/io_interface.nim index ef450d1910..6c557b15ea 100644 --- a/src/app/modules/main/profile_section/wallet/accounts/io_interface.nim +++ b/src/app/modules/main/profile_section/wallet/accounts/io_interface.nim @@ -28,6 +28,9 @@ method updateAccount*(self: AccessInterface, address: string, accountName: strin method updateAccountPosition*(self: AccessInterface, address: string, position: int) {.base.} = raise newException(ValueError, "No implementation available") +method renameKeypair*(self: AccessInterface, keyUid: string, name: string) {.base.} = + raise newException(ValueError, "No implementation available") + # View Delegate Interface # Delegate for the view must be declared here due to use of QtObject and multi # inheritance, which is not well supported in Nim. diff --git a/src/app/modules/main/profile_section/wallet/accounts/model.nim b/src/app/modules/main/profile_section/wallet/accounts/model.nim index 05816117aa..af6c47d34b 100644 --- a/src/app/modules/main/profile_section/wallet/accounts/model.nim +++ b/src/app/modules/main/profile_section/wallet/accounts/model.nim @@ -1,6 +1,8 @@ import NimQml, Tables, strutils, strformat import ./item +export item + type ModelRole {.pure.} = enum Name = UserRole + 1, diff --git a/src/app/modules/main/profile_section/wallet/accounts/module.nim b/src/app/modules/main/profile_section/wallet/accounts/module.nim index 67333e7d61..9290c2dfe7 100644 --- a/src/app/modules/main/profile_section/wallet/accounts/module.nim +++ b/src/app/modules/main/profile_section/wallet/accounts/module.nim @@ -2,15 +2,15 @@ import NimQml, sequtils, sugar, chronicles import ./io_interface, ./view, ./item, ./controller import ../io_interface as delegate_interface -import ../../../../shared/wallet_utils -import ../../../../shared/keypairs -import ../../../../shared_models/keypair_item -import ../../../../../global/global_singleton -import ../../../../../core/eventemitter -import ../../../../../../app_service/service/keycard/service as keycard_service -import ../../../../../../app_service/service/wallet_account/service as wallet_account_service -import ../../../../../../app_service/service/network/service as network_service -import ../../../../../../app_service/service/settings/service +import app/modules/shared/wallet_utils +import app/modules/shared/keypairs +import app/modules/shared_models/keypair_model +import app/global/global_singleton +import app/core/eventemitter +import app_service/service/keycard/service as keycard_service +import app_service/service/wallet_account/service as wallet_account_service +import app_service/service/network/service as network_service +import app_service/service/settings/service export io_interface @@ -39,6 +39,9 @@ proc newModule*( result.controller = controller.newController(result, walletAccountService) result.moduleLoaded = false +## Forward declarations +proc onKeypairRenamed(self: Module, keyUid: string, name: string) + method delete*(self: Module) = self.view.delete self.viewVariant.delete @@ -102,6 +105,14 @@ method load*(self: Module) = return self.refreshWalletAccounts() + self.events.on(SIGNAL_KEYPAIR_NAME_CHANGED) do(e: Args): + let args = KeypairArgs(e) + self.onKeypairRenamed(args.keypair.keyUid, args.keypair.name) + + self.events.on(SIGNAL_DISPLAY_NAME_UPDATED) do(e:Args): + let args = SettingsTextValueArgs(e) + self.onKeypairRenamed(singletonInstance.userProfile.getKeyUid(), args.value) + self.events.on(SIGNAL_WALLET_ACCOUNT_POSITION_UPDATED) do(e:Args): self.refreshWalletAccounts() @@ -131,3 +142,9 @@ method deleteAccount*(self: Module, address: string) = method toggleIncludeWatchOnlyAccount*(self: Module) = self.controller.toggleIncludeWatchOnlyAccount() + +method renameKeypair*(self: Module, keyUid: string, name: string) = + self.controller.renameKeypair(keyUid, name) + +proc onKeypairRenamed(self: Module, keyUid: string, name: string) = + self.view.keyPairModel.updateKeypairName(keyUid, name) \ No newline at end of file diff --git a/src/app/modules/main/profile_section/wallet/accounts/view.nim b/src/app/modules/main/profile_section/wallet/accounts/view.nim index 7add8b55f3..4e88bf649c 100644 --- a/src/app/modules/main/profile_section/wallet/accounts/view.nim +++ b/src/app/modules/main/profile_section/wallet/accounts/view.nim @@ -1,9 +1,8 @@ import NimQml, sequtils, strutils, sugar -import ./model -import ./item import ./io_interface -import ../../../../shared_models/[keypair_model, keypair_item] +import ./model +import app/modules/shared_models/keypair_model QtObject: type @@ -52,10 +51,13 @@ QtObject: proc onUpdatedAccount*(self: View, account: Item) = self.accounts.onUpdatedAccount(account) self.keyPairModel.onUpdatedAccount(account.keyUid, account.address, account.name, account.colorId, account.emoji) - + proc deleteAccount*(self: View, address: string) {.slot.} = self.delegate.deleteAccount(address) - + + proc keyPairModel*(self: View): KeyPairModel = + return self.keyPairModel + proc keyPairModelChanged*(self: View) {.signal.} proc getKeyPairModel(self: View): QVariant {.slot.} = return newQVariant(self.keyPairModel) @@ -80,3 +82,9 @@ QtObject: proc setIncludeWatchOnlyAccount*(self: View, includeWatchOnlyAccount: bool) = self.includeWatchOnlyAccount = includeWatchOnlyAccount self.includeWatchOnlyAccountChanged() + + proc keypairNameExists*(self: View, name: string): bool {.slot.} = + return self.keyPairModel.keypairNameExists(name) + + proc renameKeypair*(self: View, keyUid: string, name: string) {.slot.} = + self.delegate.renameKeypair(keyUid, name) \ No newline at end of file diff --git a/src/app/modules/shared_models/keypair_model.nim b/src/app/modules/shared_models/keypair_model.nim index 980ba575ce..7ce44ba122 100644 --- a/src/app/modules/shared_models/keypair_model.nim +++ b/src/app/modules/shared_models/keypair_model.nim @@ -1,4 +1,4 @@ -import NimQml, Tables, strformat +import NimQml, Tables, strformat, sequtils, sugar import keypair_item export keypair_item @@ -80,3 +80,12 @@ QtObject: if keyUid == item.getKeyUid(): item.getAccountsModel().updateDetailsForAddressIfTheyAreSet(address, name, colorId, emoji) break + + proc keypairNameExists*(self: KeyPairModel, name: string): bool = + return self.items.any(x => x.getName() == name) + + proc updateKeypairName*(self: KeyPairModel, keyUid: string, name: string) = + let item = self.findItemByKeyUid(keyUid) + if item.isNil: + return + item.setName(name) \ No newline at end of file diff --git a/src/app_service/service/wallet_account/service.nim b/src/app_service/service/wallet_account/service.nim index aeefd14b3a..7b59ef6b3c 100644 --- a/src/app_service/service/wallet_account/service.nim +++ b/src/app_service/service/wallet_account/service.nim @@ -576,6 +576,24 @@ QtObject: except Exception as e: error "error: ", procName="updateAccountPosition", errName=e.name, errDesription=e.msg + proc updateKeypairName*(self: Service, keyUid: string, name: string) = + try: + let response = backend.updateKeypairName(keyUid, name) + if not response.error.isNil: + error "status-go error", procName="updateKeypairName", errCode=response.error.code, errDesription=response.error.message + return + # Once we start maintaining local store by keypairs we will need to update that store from here, + # till then we just emit signal from here. + self.events.emit(SIGNAL_KEYPAIR_NAME_CHANGED, KeypairArgs( + keypair: KeypairDto( + keyUid: keyUid, + name: name + ) + ) + ) + except Exception as e: + error "error: ", procName="updateKeypairName", errName=e.name, errDesription=e.msg + proc fetchDerivedAddresses*(self: Service, password: string, derivedFrom: string, paths: seq[string], hashPassword: bool) = let arg = FetchDerivedAddressesTaskArg( password: if hashPassword: utils.hashPassword(password) else: password, @@ -945,10 +963,7 @@ QtObject: proc handleKeypair(self: Service, keypair: KeypairDto) = ## In some point in future instead `self.walletAccounts` table we should switch to maintaining local state in the ## form of keypairs + another list just for watch only accounts. We will benefint from that in terms of maintaining. - ## Keycards detaiils will be in that case tracked easier and stored locally as well. Also at that point we can check - ## if the local keypair name is different than one received here and emit signal only in that case, till then, - ## we emit it always. - self.events.emit(SIGNAL_KEYPAIR_NAME_CHANGED, KeypairArgs(keypair: KeypairDto(name: keypair.name))) + ## Keycards details will be in that case tracked easier and stored locally as well. # handle keypair related accounts # - first remove removed accounts from the UI diff --git a/src/backend/backend.nim b/src/backend/backend.nim index f14ceb95bb..bdbc55be7e 100644 --- a/src/backend/backend.nim +++ b/src/backend/backend.nim @@ -263,6 +263,10 @@ rpc(updateAccountPosition, "accounts"): address: string position: int +rpc(updateKeypairName, "accounts"): + keyUid: string + name: string + rpc(getHourlyMarketValues, "wallet"): symbol: string currency: string diff --git a/ui/StatusQ/src/assets.qrc b/ui/StatusQ/src/assets.qrc index 6fd71584c2..89ff3ec6eb 100644 --- a/ui/StatusQ/src/assets.qrc +++ b/ui/StatusQ/src/assets.qrc @@ -215,6 +215,7 @@ assets/img/icons/key_pair_private_key.svg assets/img/icons/key_pair_seed_phrase.svg assets/img/icons/keyboard.svg + assets/img/icons/keycard-crossed.svg assets/img/icons/keycard-logo.svg assets/img/icons/keycard.svg assets/img/icons/language.svg diff --git a/ui/StatusQ/src/assets/img/icons/keycard-crossed.svg b/ui/StatusQ/src/assets/img/icons/keycard-crossed.svg new file mode 100644 index 0000000000..6266a00d8c --- /dev/null +++ b/ui/StatusQ/src/assets/img/icons/keycard-crossed.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/app/AppLayouts/Profile/controls/WalletKeyPairDelegate.qml b/ui/app/AppLayouts/Profile/controls/WalletKeyPairDelegate.qml index 8fde0e9b0b..73bd200c79 100644 --- a/ui/app/AppLayouts/Profile/controls/WalletKeyPairDelegate.qml +++ b/ui/app/AppLayouts/Profile/controls/WalletKeyPairDelegate.qml @@ -5,6 +5,7 @@ import StatusQ.Controls 0.1 import StatusQ.Components 0.1 import StatusQ.Core 0.1 import StatusQ.Core.Theme 0.1 +import StatusQ.Popups 0.1 import utils 1.0 @@ -17,6 +18,7 @@ Rectangle { signal goToAccountView(var account) signal toggleIncludeWatchOnlyAccount() + signal runRenameKeypairFlow() QtObject { id: d @@ -62,6 +64,69 @@ Rectangle { icon.name: "more" icon.color: Theme.palette.directColor1 visible: !d.isWatchOnly + highlighted: menuLoader.item && menuLoader.item.opened + onClicked: { + menuLoader.active = true + menuLoader.item.popup(0, height) + } + + Loader { + id: menuLoader + active: false + sourceComponent: StatusMenu { + onClosed: { + menuLoader.active = false + } + + StatusAction { + text: enabled? qsTr("Show encrypted QR of keys on device") : "" + enabled: !d.isProfileKeypair + icon.name: "qr" + icon.color: Theme.palette.primaryColor1 + onTriggered: { + console.warn("TODO: show encrypted QR") + } + } + + + StatusAction { + text: model.keyPair.migratedToKeycard? qsTr("Stop using Keycard") : qsTr("Move keys to a Keycard") + icon.name: model.keyPair.migratedToKeycard? "keycard-crossed" : "keycard" + icon.color: Theme.palette.primaryColor1 + onTriggered: { + if (model.keyPair.migratedToKeycard) + console.warn("TODO: stop using Keycard") + else + console.warn("TODO: move keys to a Keycard") + } + } + + StatusAction { + text: enabled? qsTr("Rename keypair") : "" + enabled: !d.isProfileKeypair + icon.name: "edit" + icon.color: Theme.palette.primaryColor1 + onTriggered: { + root.runRenameKeypairFlow() + } + } + + StatusMenuSeparator { + visible: !d.isProfileKeypair + } + + StatusAction { + text: enabled? qsTr("Remove master keys and associated accounts") : "" + enabled: !d.isProfileKeypair + type: StatusAction.Type.Danger + icon.name: "delete" + icon.color: Theme.palette.dangerColor1 + onTriggered: { + console.warn("TODO: remove master keys and associated accounts") + } + } + } + } }, StatusBaseText { anchors.verticalCenter: parent.verticalCenter diff --git a/ui/app/AppLayouts/Profile/panels/ProfileDescriptionPanel.qml b/ui/app/AppLayouts/Profile/panels/ProfileDescriptionPanel.qml index ec83c04d9b..d9327f2335 100644 --- a/ui/app/AppLayouts/Profile/panels/ProfileDescriptionPanel.qml +++ b/ui/app/AppLayouts/Profile/panels/ProfileDescriptionPanel.qml @@ -32,7 +32,7 @@ Item { label: qsTr("Display name") placeholderText: qsTr("Display Name") - charLimit: 24 + charLimit: Constants.keypair.nameLengthMax validators: Constants.validators.displayName input.tabNavItem: bioInput.input.edit diff --git a/ui/app/AppLayouts/Profile/popups/RenameKeypairPopup.qml b/ui/app/AppLayouts/Profile/popups/RenameKeypairPopup.qml new file mode 100644 index 0000000000..a4bb766e5b --- /dev/null +++ b/ui/app/AppLayouts/Profile/popups/RenameKeypairPopup.qml @@ -0,0 +1,153 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import StatusQ.Core 0.1 +import StatusQ.Core.Theme 0.1 +import StatusQ.Core.Utils 0.1 +import StatusQ.Controls 0.1 +import StatusQ.Components 0.1 +import StatusQ.Popups 0.1 + +import utils 1.0 +import shared.controls 1.0 + +import "../stores" + +StatusModal { + id: root + + required property var accountsModule + required property string keyUid + required property string name + required property var accounts + + headerSettings.title: qsTr("Rename keypair") + focus: visible + padding: Style.current.padding + + QtObject { + id: d + + property bool entryValid: false + + function updateValidity() { + d.entryValid = nameInput.valid + if (!d.entryValid) { + return + } + d.entryValid = d.entryValid && nameInput.text !== root.name + if (!d.entryValid) { + nameInput.errorMessageCmp.text = qsTr("Same name") + nameInput.valid = false + return + } + d.entryValid = d.entryValid && !root.accountsModule.keypairNameExists(nameInput.text) + if (!d.entryValid) { + nameInput.errorMessageCmp.text = qsTr("Key name already in use") + nameInput.valid = false + } + } + + function confirm() { + if (d.entryValid) { + root.accountsModule.renameKeypair(root.keyUid, nameInput.text) + root.close() + } + } + } + + contentItem: ColumnLayout { + spacing: Style.current.halfPadding + + StatusInput { + id: nameInput + Layout.preferredWidth: parent.width + Layout.preferredHeight: 120 + topPadding: 8 + bottomPadding: 8 + label: qsTr("Key name") + charLimit: Constants.keypair.nameLengthMax + validators: Constants.validators.keypairName + input.clearable: true + input.rightPadding: 16 + text: root.name + + onTextChanged: { + d.updateValidity() + } + } + + StatusBaseText { + Layout.preferredWidth: parent.width + Layout.topMargin: Style.current.padding + text: qsTr("Accounts derived from this key") + font.pixelSize: Style.current.primaryTextFontSize + } + + Rectangle { + Layout.preferredWidth: parent.width + Layout.preferredHeight: 60 + color: "transparent" + radius: 8 + border.width: 1 + border.color: Theme.palette.baseColor2 + + StatusScrollView { + anchors.verticalCenter: parent.verticalCenter + width: parent.width + height: contentHeight + padding: 0 + leftPadding: 16 + rightPadding: 16 + + Row { + spacing: 10 + Repeater { + model: root.accounts + delegate: StatusListItemTag { + bgColor: Utils.getColorForId(model.account.colorId) + height: Style.current.bigPadding + bgRadius: 6 + tagClickable: false + closeButtonVisible: false + asset { + emoji: model.account.emoji + emojiSize: Emoji.size.verySmall + isLetterIdenticon: !!model.account.emoji + name: model.account.icon + color: Theme.palette.indirectColor1 + width: 16 + height: 16 + } + title: model.account.name + titleText.font.pixelSize: 12 + titleText.color: Theme.palette.indirectColor1 + } + } + } + } + } + } + + rightButtons: [ + StatusFlatButton { + text: qsTr("Cancel") + type: StatusBaseButton.Type.Normal + onClicked: { + root.close() + } + }, + StatusButton { + text: qsTr("Save changes") + enabled: d.entryValid + focus: true + Keys.onReturnPressed: function(event) { + d.confirm() + } + onClicked: { + d.confirm() + } + } + ] +} diff --git a/ui/app/AppLayouts/Profile/popups/qmldir b/ui/app/AppLayouts/Profile/popups/qmldir index cdbb36aaff..2bd65857e9 100644 --- a/ui/app/AppLayouts/Profile/popups/qmldir +++ b/ui/app/AppLayouts/Profile/popups/qmldir @@ -2,3 +2,4 @@ BackupSeedModal 1.0 BackupSeedModal.qml SetupSyncingPopup 1.0 SetupSyncingPopup.qml AddSocialLinkModal 1.0 AddSocialLinkModal.qml ModifySocialLinkModal 1.0 ModifySocialLinkModal.qml +RenameKeypairPopup 1.0 RenameKeypairPopup.qml \ No newline at end of file diff --git a/ui/app/AppLayouts/Profile/views/wallet/MainView.qml b/ui/app/AppLayouts/Profile/views/wallet/MainView.qml index ce329df1a2..53d9c94785 100644 --- a/ui/app/AppLayouts/Profile/views/wallet/MainView.qml +++ b/ui/app/AppLayouts/Profile/views/wallet/MainView.qml @@ -105,7 +105,37 @@ Column { includeWatchOnlyAccount: walletStore.includeWatchOnlyAccount onGoToAccountView: root.goToAccountView(account) onToggleIncludeWatchOnlyAccount: walletStore.toggleIncludeWatchOnlyAccount() + onRunRenameKeypairFlow: { + renameKeypairPopup.keyUid = model.keyPair.keyUid + renameKeypairPopup.name = model.keyPair.name + renameKeypairPopup.accounts = model.keyPair.accounts + renameKeypairPopup.active = true + } } } } + + Loader { + id: renameKeypairPopup + active: false + + property string keyUid + property string name + property var accounts + + sourceComponent: RenameKeypairPopup { + accountsModule: root.walletStore.accountsModule + keyUid: renameKeypairPopup.keyUid + name: renameKeypairPopup.name + accounts: renameKeypairPopup.accounts + + onClosed: { + renameKeypairPopup.active = false + } + } + + onLoaded: { + renameKeypairPopup.item.open() + } + } } diff --git a/ui/app/AppLayouts/Wallet/popups/qmldir b/ui/app/AppLayouts/Wallet/popups/qmldir index 39b4aef3d3..7da5cfacd8 100644 --- a/ui/app/AppLayouts/Wallet/popups/qmldir +++ b/ui/app/AppLayouts/Wallet/popups/qmldir @@ -1,4 +1,4 @@ NetworkSelectPopup 1.0 NetworkSelectPopup.qml ActivityFilterMenu 1.0 ActivityFilterMenu.qml ActivityPeriodFilterSubMenu 1.0 filterSubMenus/ActivityPeriodFilterSubMenu.qml -ActivityTypeFilterSubMenu 1.0 filterSubMenus/ActivityTypeFilterSubMenu.qml +ActivityTypeFilterSubMenu 1.0 filterSubMenus/ActivityTypeFilterSubMenu.qml \ No newline at end of file diff --git a/ui/imports/utils/Constants.qml b/ui/imports/utils/Constants.qml index 93cb1588d3..9a48b871d2 100644 --- a/ui/imports/utils/Constants.qml +++ b/ui/imports/utils/Constants.qml @@ -466,7 +466,28 @@ QtObject { readonly property int blockedContacts: 6 } + readonly property QtObject keypair: QtObject { + readonly property int nameLengthMax: 20 + readonly property int nameLengthMin: 5 + } + readonly property QtObject validators: QtObject { + readonly property list keypairName: [ + StatusValidator { + name: "startsWithSpaceValidator" + validate: function (t) { return !t.startsWith(" ") } + errorMessage: qsTr("Keypair starting with whitespace are not allowed") + }, + StatusRegularExpressionValidator { + regularExpression: /^[a-zA-Z0-9\-_ ]+$/ + errorMessage: errorMessages.alphanumericalExpandedRegExp + }, + StatusMinLengthValidator { + minLength: keypair.nameLengthMin + errorMessage: qsTr("Keypair must be at least %n character(s)", "", keypair.nameLengthMin) + } + ] + readonly property list displayName: [ StatusValidator { name: "startsWithSpaceValidator" @@ -475,11 +496,11 @@ QtObject { }, StatusRegularExpressionValidator { regularExpression: /^[a-zA-Z0-9\-_ ]+$/ - errorMessage: qsTr("Only letters, numbers, underscores, whitespaces and hyphens allowed") + errorMessage: errorMessages.alphanumericalExpandedRegExp }, StatusMinLengthValidator { - minLength: 5 - errorMessage: qsTr("Username must be at least 5 characters") + minLength: keypair.nameLengthMin + errorMessage: qsTr("Username must be at least %1 characters").arg(keypair.nameLengthMin) }, StatusValidator { name: "endsWithSpaceValidator" @@ -489,8 +510,8 @@ QtObject { // TODO: Create `StatusMaxLengthValidator` in StatusQ StatusValidator { name: "maxLengthValidator" - validate: function (t) { return t.length <= 24 } - errorMessage: qsTr("24 character username limit") + validate: function (t) { return t.length <= keypair.nameLengthMax } + errorMessage: qsTr("%n character(s) username limit", "", keypair.nameLengthMax) }, StatusValidator { name: "endsWith-ethValidator" @@ -575,7 +596,7 @@ QtObject { readonly property int enterSeedPhraseWordsHeight: 60 readonly property int keycardPinLength: 6 readonly property int keycardPukLength: 12 - readonly property int keycardNameLength: 20 + readonly property int keycardNameLength: keypair.nameLengthMax readonly property int keycardNameInputWidth: 448 readonly property int keycardPairingCodeInputWidth: 512 readonly property int keycardPukAdditionalSpacingOnEvery4Items: 4