From 5bf4ba22c21646c5078940f4f073360c8feeec59 Mon Sep 17 00:00:00 2001 From: Cuteivist Date: Wed, 4 Dec 2024 17:19:27 +0100 Subject: [PATCH] feat: Payment Request modal (#16744) --- src/app/global/feature_flags.nim | 9 + storybook/pages/PaymentRequestModalPage.qml | 157 +++++++++ storybook/pages/StatusChatInputPage.qml | 19 + .../tests/tst_PaymentRequestModal.qml | 326 ++++++++++++++++++ storybook/stubs/shared/stores/qmldir | 1 + ui/app/AppLayouts/Chat/ChatLayout.qml | 4 + .../Chat/popups/PaymentRequestModal.qml | 250 ++++++++++++++ ui/app/AppLayouts/Chat/popups/qmldir | 1 + .../AppLayouts/Chat/views/ChatColumnView.qml | 7 + ui/app/AppLayouts/Chat/views/ChatView.qml | 4 + .../AppLayouts/Chat/views/CreateChatView.qml | 1 + .../Wallet/stores/WalletAssetsStore.qml | 7 +- .../AppLayouts/stores/FeatureFlagsStore.qml | 1 + ui/app/mainui/AppMain.qml | 5 + ui/app/mainui/Popups.qml | 35 ++ .../shared/popups/send/views/AmountToSend.qml | 3 +- ui/imports/shared/status/StatusChatInput.qml | 63 +++- ui/imports/utils/Global.qml | 2 + 18 files changed, 882 insertions(+), 13 deletions(-) create mode 100644 storybook/pages/PaymentRequestModalPage.qml create mode 100644 storybook/qmlTests/tests/tst_PaymentRequestModal.qml create mode 100644 ui/app/AppLayouts/Chat/popups/PaymentRequestModal.qml diff --git a/src/app/global/feature_flags.nim b/src/app/global/feature_flags.nim index 6ed50a9a6e3..d78760bf4f7 100644 --- a/src/app/global/feature_flags.nim +++ b/src/app/global/feature_flags.nim @@ -5,6 +5,7 @@ const DEFAULT_FLAG_DAPPS_ENABLED = true const DEFAULT_FLAG_SWAP_ENABLED = true const DEFAULT_FLAG_CONNECTOR_ENABLED* = true const DEFAULT_FLAG_SEND_VIA_PERSONAL_CHAT_ENABLED = true +const DEFAULT_FLAG_PAYMENT_REQUEST_ENABLED = true proc boolToEnv*(defaultValue: bool): string = return if defaultValue: "1" else: "0" @@ -15,6 +16,7 @@ QtObject: swapEnabled: bool connectorEnabled: bool sendViaPersonalChatEnabled: bool + paymentRequestEnabled: bool proc setup(self: FeatureFlags) = self.QObject.setup() @@ -22,6 +24,7 @@ QtObject: self.swapEnabled = getEnv("FLAG_SWAP_ENABLED", boolToEnv(DEFAULT_FLAG_SWAP_ENABLED)) != "0" self.connectorEnabled = getEnv("FLAG_CONNECTOR_ENABLED", boolToEnv(DEFAULT_FLAG_CONNECTOR_ENABLED)) != "0" self.sendViaPersonalChatEnabled = getEnv("FLAG_SEND_VIA_PERSONAL_CHAT_ENABLED", boolToEnv(DEFAULT_FLAG_SEND_VIA_PERSONAL_CHAT_ENABLED)) != "0" + self.paymentRequestEnabled = getEnv("FLAG_PAYMENT_REQUEST_ENABLED", boolToEnv(DEFAULT_FLAG_PAYMENT_REQUEST_ENABLED)) != "0" proc delete*(self: FeatureFlags) = self.QObject.delete() @@ -53,3 +56,9 @@ QtObject: QtProperty[bool] sendViaPersonalChatEnabled: read = getSendViaPersonalChatEnabled + + proc getPaymentRequestEnabled*(self: FeatureFlags): bool {.slot.} = + return self.paymentRequestEnabled + + QtProperty[bool] paymentRequestEnabled: + read = getPaymentRequestEnabled diff --git a/storybook/pages/PaymentRequestModalPage.qml b/storybook/pages/PaymentRequestModalPage.qml new file mode 100644 index 00000000000..9413df43871 --- /dev/null +++ b/storybook/pages/PaymentRequestModalPage.qml @@ -0,0 +1,157 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import SortFilterProxyModel 0.2 +import QtTest 1.15 + +import StatusQ 0.1 +import StatusQ.Controls 0.1 +import StatusQ.Core 0.1 +import StatusQ.Core.Backpressure 0.1 +import StatusQ.Core.Utils 0.1 + +import utils 1.0 +import Storybook 1.0 +import Models 1.0 + +import mainui 1.0 +import AppLayouts.Wallet.stores 1.0 as WalletStores +import AppLayouts.Wallet.adaptors 1.0 +import AppLayouts.Chat.popups 1.0 +import AppLayouts.stores 1.0 as AppLayoutStores +import shared.stores 1.0 as SharedStores + +SplitView { + id: root + + Logs { id: logs } + + orientation: Qt.Horizontal + + QtObject { + id: d + + function launchPopup() { + const modal = paymentRequestModalComponent.createObject(root) + modal.open() + } + + readonly property var accounts: WalletAccountsModel {} + readonly property var flatNetworks: NetworksModel.flatNetworks + + readonly property string selectedAccountAddress: ctrlAccount.currentValue ?? "" + readonly property int selectedNetworkChainId: ctrlSelectedNetworkChainId.currentValue ?? -1 + } + + PopupBackground { + id: popupBg + + SplitView.fillWidth: true + SplitView.fillHeight: true + + Button { + id: reopenButton + anchors.centerIn: parent + text: "Reopen" + enabled: !paymentRequestModalComponent.visible + + onClicked: d.launchPopup() + } + + Component.onCompleted: Qt.callLater(d.launchPopup) + + Component { + id: paymentRequestModalComponent + PaymentRequestModal { + id: paymentRequestModal + modal: false + closePolicy: Popup.CloseOnEscape + destroyOnClose: true + + currentCurrency: currenciesStore.currentCurrency + formatCurrencyAmount: currenciesStore.formatCurrencyAmount + flatNetworksModel: d.flatNetworks + accountsModel: d.accounts + assetsModel: tokenAdaptor.outputAssetsModel + + readonly property SharedStores.CurrenciesStore currenciesStore: SharedStores.CurrenciesStore {} + readonly property var tokenAdaptor: TokenSelectorViewAdaptor { + assetsModel: null + flatNetworksModel: d.flatNetworks + currentCurrency: paymentRequestModal.currentCurrency + plainTokensBySymbolModel: TokensBySymbolModel {} + showAllTokens: true + } + + Connections { + target: d + function onSelectedNetworkChainIdChanged() { + paymentRequestModal.selectedNetworkChainId = d.selectedNetworkChainId + } + function onSelectedAccountAddressChanged() { + paymentRequestModal.selectedAccountAddress = d.selectedAccountAddress + } + } + Component.onCompleted: { + if (d.selectedNetworkChainId > -1) + paymentRequestModal.selectedNetworkChainId = d.selectedNetworkChainId + if (!!d.selectedAccountAddress) + paymentRequestModal.selectedAccountAddress = d.selectedAccountAddress + } + } + } + } + + ScrollView { + id: rightPanel + SplitView.minimumWidth: 300 + SplitView.preferredWidth: 300 + SplitView.minimumHeight: 300 + + ColumnLayout { + anchors.top: parent.top + anchors.left: parent.left + anchors.margins: 10 + spacing: 10 + + Label { + text: "pre-selection:" + } + + RowLayout { + Layout.fillWidth: true + Label { + text: "Chain:" + } + ComboBox { + Layout.fillWidth: true + id: ctrlSelectedNetworkChainId + model: d.flatNetworks + textRole: "chainName" + valueRole: "chainId" + displayText: currentIndex === -1 ? "All chains" : currentText + currentIndex: -1 // all chains + } + } + + RowLayout { + Layout.fillWidth: true + Label { text: "Account:" } + ComboBox { + Layout.fillWidth: true + id: ctrlAccount + textRole: "name" + valueRole: "address" + displayText: currentText || "----" + model: SortFilterProxyModel { + sourceModel: d.accounts + sorters: RoleSorter { roleName: "position" } + } + currentIndex: -1 + } + } + } + } +} + +// category: Popups diff --git a/storybook/pages/StatusChatInputPage.qml b/storybook/pages/StatusChatInputPage.qml index d4fadb6c2e3..4bceb47a48a 100644 --- a/storybook/pages/StatusChatInputPage.qml +++ b/storybook/pages/StatusChatInputPage.qml @@ -11,6 +11,7 @@ import shared.stores 1.0 as SharedStores import StatusQ.Core.Utils 0.1 as SQUtils +import AppLayouts.Wallet.stores 1.0 as WalletStores import AppLayouts.Chat.stores 1.0 as ChatStores SplitView { @@ -99,6 +100,9 @@ SplitView { } } + paymentRequestFeatureEnabled: true + areTestNetworksEnabled: testnetEnabledCheckBox.checked + onSendMessage: { logs.logEvent("StatusChatInput::sendMessage", ["MessageWithPk"], [chatInput.getTextWithPublicKeys()]) logs.logEvent("StatusChatInput::sendMessage", ["PlainText"], [SQUtils.StringUtils.plainText(chatInput.getTextWithPublicKeys())]) @@ -138,6 +142,15 @@ SplitView { QtObject { id: d + + readonly property var walletAssetsStore: WalletStores.WalletAssetsStore { + id: thisWalletAssetStore + walletTokensStore: WalletStores.TokensStore { + plainTokensBySymbolModel: TokensBySymbolModel {} + } + assetsWithFilteredBalances: thisWalletAssetStore.groupedAccountsAssetsModel + } + property bool linkPreviewsEnabled: linkPreviewSwitch.checked && !askToEnableLinkPreviewSwitch.checked onLinkPreviewsEnabledChanged: { loadLinkPreviews(chatInputLoader.item ? chatInputLoader.item.unformattedText : "") @@ -172,6 +185,12 @@ SplitView { checked: true } + CheckBox { + id: testnetEnabledCheckBox + text: "testnet enabled" + checked: false + } + TabBar { id: bar TabButton { diff --git a/storybook/qmlTests/tests/tst_PaymentRequestModal.qml b/storybook/qmlTests/tests/tst_PaymentRequestModal.qml new file mode 100644 index 00000000000..c704285c6b4 --- /dev/null +++ b/storybook/qmlTests/tests/tst_PaymentRequestModal.qml @@ -0,0 +1,326 @@ +import QtQuick 2.15 +import QtTest 1.15 + +import StatusQ.Core 0.1 +import StatusQ.Core.Utils 0.1 as SQUtils +import StatusQ.Core.Theme 0.1 +import StatusQ.Controls 0.1 + +import QtQuick.Controls 2.15 + +import Models 1.0 +import Storybook 1.0 + +import utils 1.0 + +import AppLayouts.Wallet.stores 1.0 as WalletStores +import AppLayouts.Wallet.adaptors 1.0 +import AppLayouts.Chat.popups 1.0 +import shared.stores 1.0 as SharedStores + +Item { + id: root + width: 800 + height: 600 + + QtObject { + id: d + + readonly property var accounts: WalletAccountsModel {} + readonly property var flatNetworks: NetworksModel.flatNetworks + } + + Component { + id: paymentRequestModalComponent + PaymentRequestModal { + id: paymentRequestModal + destroyOnClose: true + + readonly property SharedStores.CurrenciesStore currencyStore: SharedStores.CurrenciesStore {} + + currentCurrency: currencyStore.currentCurrency + formatCurrencyAmount: currencyStore.formatCurrencyAmount + flatNetworksModel: d.flatNetworks + accountsModel: d.accounts + assetsModel: ListModel { + Component.onCompleted: append([{ + tokensKey: "ETH", + name: "eth", + symbol: "ETH", + chainId: NetworksModel.ethNet, + address: "0xbbc200", + decimals: "18", + iconSource: ModelsData.assets.eth, + marketDetails: { + currencyPrice: { + amount: 1, + displayDecimals: true + } + } + }, + { + tokensKey: "SNT", + name: "snt", + symbol: "SNT", + chainId: NetworksModel.ethNet, + address: "0xbbc2000000000000000000000000000000000123", + decimals: "18", + iconSource: ModelsData.assets.snt, + marketDetails: { + currencyPrice: { + amount: 1, + displayDecimals: true + } + } + }, + { + tokensKey: "DAI", + name: "dai", + symbol: "DAI", + chainId: NetworksModel.ethNet, + address: "0xbbc2000000000000000000000000000000550567", + decimals: "2", + iconSource: ModelsData.assets.dai, + marketDetails: { + currencyPrice: { + amount: 1, + displayDecimals: true + } + } + }]) + } + } + } + + TestCase { + name: "PaymentRequestModal" + when: windowShown + + property PaymentRequestModal controlUnderTest: null + + // helper functions ------------------------------------------------------------- + function launchAndVerfyModal() { + controlUnderTest = createTemporaryObject(paymentRequestModalComponent, root) + verify(!!controlUnderTest) + controlUnderTest.open() + waitForRendering(controlUnderTest.contentItem) + verify(!!controlUnderTest.opened) + } + + function closeAndVerfyModal() { + verify(!!controlUnderTest) + controlUnderTest.close() + verify(!controlUnderTest.opened) + } + // end helper functions ------------------------------------------------------------- + + function test_default_values() { + launchAndVerfyModal() + + const button = findChild(controlUnderTest, "addButton") + verify(!!button) + verify(!button.enabled) + + compare(controlUnderTest.selectedTokenKey, Constants.ethToken) + const assetSelector = findChild(controlUnderTest, "assetSelector") + verify(!!assetSelector) + verify(assetSelector.isSelected) + verify(assetSelector.contentItem.selected) + compare(assetSelector.contentItem.name, Constants.ethToken) + + compare(controlUnderTest.selectedNetworkChainId, Constants.chains.mainnetChainId) + const networkSelector = findChild(controlUnderTest, "networkSelector") + verify(!!networkSelector) + compare(networkSelector.control.contentItem.title, Constants.networkMainnet) + compare(networkSelector.count, d.flatNetworks.count) + + compare(controlUnderTest.selectedAccountAddress, d.accounts.data[0].address) + const accountSelector = findChild(controlUnderTest, "accountSelector") + verify(!!accountSelector) + compare(accountSelector.control.contentItem.name, d.accounts.data[0].name) + compare(accountSelector.count, d.accounts.data.length) + + compare(controlUnderTest.amount, "0") + const amountInput = findChild(controlUnderTest, "amountInput") + verify(!!amountInput) + compare(amountInput.text, "") + + closeAndVerfyModal() + } + + function test_change_amount() { + launchAndVerfyModal() + + const amountInput = findChild(controlUnderTest, "amountInput") + verify(!!amountInput) + + const amount = "1.24" + amountInput.setValue(amount) + compare(amountInput.text, amount) + compare(controlUnderTest.amount, "1240000000000000000") // Raw amount is returned + + closeAndVerfyModal() + } + + function test_change_address() { + launchAndVerfyModal() + + const accountSelector = findChild(controlUnderTest, "accountSelector") + verify(!!accountSelector) + + const account = d.accounts.data[1] + mouseClick(accountSelector) + verify(accountSelector.control.popup.opened) + waitForRendering(accountSelector.control.popup.contentItem) + const delegateUnderTest = accountSelector.control.popup.contentItem.itemAtIndex(1) + verify(!!delegateUnderTest) + mouseClick(delegateUnderTest) + verify(!accountSelector.control.popup.opened) + + compare(controlUnderTest.selectedAccountAddress, account.address) + compare(accountSelector.control.contentItem.name, account.name) + + closeAndVerfyModal() + } + + function test_change_symbol() { + launchAndVerfyModal() + + const assetSelector = findChild(controlUnderTest, "assetSelector") + verify(!!assetSelector) + compare(controlUnderTest.selectedTokenKey, "ETH") + compare(assetSelector.contentItem.name, "ETH") + + const asset = SQUtils.ModelUtils.get(assetSelector.model, 2) + verify(!!asset) + mouseClick(assetSelector) + + waitForRendering(assetSelector) + const searchablePanel = findChild(assetSelector, "searchableAssetsPanel") + verify(!!searchablePanel) + const assetsList = findChild(searchablePanel, "assetsListView") + verify(!!assetsList) + const delegateUnderTest = assetsList.itemAtIndex(2) + verify(!!delegateUnderTest) + mouseClick(delegateUnderTest) + + compare(controlUnderTest.selectedTokenKey, asset.tokensKey) + compare(assetSelector.contentItem.name, asset.symbol) + + closeAndVerfyModal() + } + + function test_change_network() { + launchAndVerfyModal() + + const networkSelector = findChild(controlUnderTest, "networkSelector") + verify(!!networkSelector) + + const network = d.flatNetworks.get(1) + mouseClick(networkSelector) + verify(networkSelector.control.popup.opened) + waitForRendering(networkSelector.control.popup.contentItem) + const delegateUnderTest = networkSelector.control.popup.contentItem.itemAtIndex(1) + verify(!!delegateUnderTest) + mouseClick(delegateUnderTest) + verify(!networkSelector.control.popup.opened) + + compare(controlUnderTest.selectedNetworkChainId, network.chainId) + compare(networkSelector.control.contentItem.title, network.chainName) + + closeAndVerfyModal() + } + + function test_open_initial_account_address() { + const account = d.accounts.data[1] + controlUnderTest = createTemporaryObject(paymentRequestModalComponent, root, { selectedAccountAddress: account.address }) + verify(!!controlUnderTest) + + controlUnderTest.open() + verify(!!controlUnderTest.opened) + + compare(controlUnderTest.selectedAccountAddress, account.address) + const accountSelector = findChild(controlUnderTest, "accountSelector") + verify(!!accountSelector) + compare(accountSelector.control.contentItem.name, account.name) + + closeAndVerfyModal() + } + + function test_open_initial_network() { + const network = d.flatNetworks.get(2) + controlUnderTest = createTemporaryObject(paymentRequestModalComponent, root, { selectedNetworkChainId: network.chainId }) + verify(!!controlUnderTest) + + controlUnderTest.open() + verify(!!controlUnderTest.opened) + + compare(controlUnderTest.selectedNetworkChainId, network.chainId) + const networkSelector = findChild(controlUnderTest, "networkSelector") + verify(!!networkSelector) + compare(networkSelector.control.contentItem.title, network.chainName) + + closeAndVerfyModal() + } + + function test_open_initial_asset() { + const asset = "DAI" + controlUnderTest = createTemporaryObject(paymentRequestModalComponent, root, { selectedTokenKey: asset }) + verify(!!controlUnderTest) + + controlUnderTest.open() + verify(!!controlUnderTest.opened) + + compare(controlUnderTest.selectedTokenKey, asset) + const assetSelector = findChild(controlUnderTest, "assetSelector") + verify(!!assetSelector) + verify(assetSelector.isSelected) + verify(assetSelector.contentItem.selected) + compare(assetSelector.contentItem.name, asset) + + closeAndVerfyModal() + } + + function test_accept_button_enabled_state() { + launchAndVerfyModal() + + const button = findChild(controlUnderTest, "addButton") + verify(!!button) + + verify(!button.enabled, "Enabled by default because default amount is 0") + + const amountInput = findChild(controlUnderTest, "amountInput") + verify(!!amountInput) + amountInput.setValue("1.24") + verify(button.enabled, "All values are filled") + amountInput.setValue("0") + verify(!button.enabled, "Amount cannot be 0") + + amountInput.setValue("2") + verify(button.enabled) + + // Below scenarios are unlikely to happen in real life, but we should test them anyway. + // This might produce warnings in the console, but it's fine. + + // Check if button changes after network is changed + controlUnderTest.selectedNetworkChainId = 0 + verify(!button.enabled) + controlUnderTest.selectedNetworkChainId = d.flatNetworks.get(1).chainId + verify(button.enabled) + + // Check if button changes after account is changed + controlUnderTest.selectedAccountAddress = "" + verify(!button.enabled) + controlUnderTest.selectedAccountAddress = d.accounts.data[1].address + verify(button.enabled) + + // Check if button changes after symbol is changed + controlUnderTest.selectedTokenKey = "" + verify(!button.enabled) + controlUnderTest.selectedTokenKey = "DAI" + verify(button.enabled) + + closeAndVerfyModal() + } + } +} diff --git a/storybook/stubs/shared/stores/qmldir b/storybook/stubs/shared/stores/qmldir index 2e0c0181a73..73ce5563415 100644 --- a/storybook/stubs/shared/stores/qmldir +++ b/storybook/stubs/shared/stores/qmldir @@ -5,6 +5,7 @@ DAppsStore 1.0 DAppsStore.qml GifStore 1.0 GifStore.qml NetworkConnectionStore 1.0 NetworkConnectionStore.qml PermissionsStore 1.0 PermissionsStore.qml +ProfileStore 1.0 ProfileStore.qml RootStore 1.0 RootStore.qml UtilsStore 1.0 UtilsStore.qml BrowserConnectStore 1.0 BrowserConnectStore.qml diff --git a/ui/app/AppLayouts/Chat/ChatLayout.qml b/ui/app/AppLayouts/Chat/ChatLayout.qml index 6d5d8da072e..1d9f064c791 100644 --- a/ui/app/AppLayouts/Chat/ChatLayout.qml +++ b/ui/app/AppLayouts/Chat/ChatLayout.qml @@ -36,6 +36,8 @@ StackLayout { required property SendStores.TransactionStore transactionStore required property WalletStore.WalletAssetsStore walletAssetsStore required property SharedStores.CurrenciesStore currencyStore + property bool areTestNetworksEnabled + property bool paymentRequestFeatureEnabled property var mutualContactsModel property var sectionItemModel @@ -171,12 +173,14 @@ StackLayout { sendModalPopup: root.sendModalPopup sectionItemModel: root.sectionItemModel joinedMembersCount: membersModelAdaptor.joinedMembers.ModelCount.count + areTestNetworksEnabled: root.areTestNetworksEnabled amIMember: sectionItem.amIMember amISectionAdmin: root.sectionItemModel.memberRole === Constants.memberRole.owner || root.sectionItemModel.memberRole === Constants.memberRole.admin || root.sectionItemModel.memberRole === Constants.memberRole.tokenMaster hasViewOnlyPermissions: root.permissionsStore.viewOnlyPermissionsModel.count > 0 sendViaPersonalChatEnabled: root.sendViaPersonalChatEnabled + paymentRequestFeatureEnabled: root.paymentRequestFeatureEnabled hasUnrestrictedViewOnlyPermission: { viewOnlyUnrestrictedPermissionHelper.revision diff --git a/ui/app/AppLayouts/Chat/popups/PaymentRequestModal.qml b/ui/app/AppLayouts/Chat/popups/PaymentRequestModal.qml new file mode 100644 index 00000000000..8dccfbc46b7 --- /dev/null +++ b/ui/app/AppLayouts/Chat/popups/PaymentRequestModal.qml @@ -0,0 +1,250 @@ +import QtQml.Models 2.15 +import QtQuick 2.15 +import QtQuick.Layouts 1.15 + +import StatusQ 0.1 +import StatusQ.Components 0.1 +import StatusQ.Components.private 0.1 as SQP +import StatusQ.Controls 0.1 +import StatusQ.Core 0.1 +import StatusQ.Core.Theme 0.1 +import StatusQ.Popups.Dialog 0.1 + +import AppLayouts.Wallet.controls 1.0 + +import shared.controls 1.0 +import shared.popups.send.views 1.0 +import utils 1.0 + +StatusDialog { + id: root + + /** + Expected model structure: + chainId [int] - networks's unique chain identifier + chainName [string] - networks's chain name + iconUrl [string] - networks's icon url + **/ + required property var flatNetworksModel + /** + Expected model structure: + address [string] - account's unique address + name [string] - account's name + emoji [string] - account's emoji + color [string] - account's wallet color + **/ + required property var accountsModel + /** Expected model structure: see SearchableAssetsPanel::model **/ + required property var assetsModel + required property string currentCurrency + property var formatCurrencyAmount: function() {} + + // input / output + property int selectedNetworkChainId: Constants.chains.mainnetChainId + property string selectedAccountAddress + property string selectedTokenKey: Constants.ethToken + + // output + readonly property string amount: { + if (!d.isSelectedHoldingValidAsset || !d.selectedHolding.item.marketDetails || !d.selectedHolding.item.marketDetails.currencyPrice) { + return "0" + } + return amountToSendInput.amount + } + + objectName: "paymentRequestModal" + + implicitWidth: 480 + implicitHeight: 470 + + modal: true + padding: 0 + backgroundColor: Theme.palette.statusModal.backgroundColor + + title: qsTr("Payment request") + + onOpened: { + // Setting value here because to prevent not updating when selected token key is filled + d.selectedHolding.value = Qt.binding(() => root.selectedTokenKey) + + if (!!root.selectedTokenKey && d.selectedHolding.available) { + holdingSelector.setSelection(d.selectedHolding.item.symbol, d.selectedHolding.item.iconSource, d.selectedHolding.item.tokensKey) + } + amountToSendInput.forceActiveFocus() + } + + QtObject { + id: d + + readonly property ModelEntry selectedHolding: ModelEntry { + sourceModel: holdingSelector.model + key: "tokensKey" + } + + readonly property bool isSelectedHoldingValidAsset: !!selectedHolding.item + } + + footer: StatusDialogFooter { + StatusDialogDivider { + anchors.top: parent.top + width: parent.width + } + rightButtons: ObjectModel { + StatusButton { + objectName: "addButton" + text: qsTr("Add to message") + disabledColor: Theme.palette.directColor8 + enabled: amountToSendInput.valid + && !amountToSendInput.empty + && amountToSendInput.amount > 0 + && root.selectedAccountAddress !== "" + && root.selectedNetworkChainId > 0 + && root.selectedTokenKey !== "" + interactive: true + onClicked: root.accept() + } + } + } + + ColumnLayout { + anchors.top: parent.top + anchors.topMargin: Theme.bigPadding + anchors.left: parent.left + anchors.leftMargin: Theme.padding + anchors.right: parent.right + anchors.rightMargin: Theme.padding + + spacing: Theme.padding + + AmountToSend { + id: amountToSendInput + objectName: "amountInput" + Layout.fillWidth: true + + readonly property bool ready: valid && !empty + + multiplierIndex: d.isSelectedHoldingValidAsset && !!d.selectedHolding.item.decimals ? d.selectedHolding.item.decimals : 0 + price: d.isSelectedHoldingValidAsset && !!d.selectedHolding.item.marketDetails ? d.selectedHolding.item.marketDetails.currencyPrice.amount : 1 + + formatFiat: amount => root.formatCurrencyAmount( + amount, root.currentCurrency) + formatBalance: amount => root.formatCurrencyAmount( + amount, root.selectedTokenKey) + + dividerVisible: true + + AssetSelector { + id: holdingSelector + objectName: "assetSelector" + + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: -(Theme.halfPadding / 2) + + model: root.assetsModel + onSelected: root.selectedTokenKey = key + } + } + + StatusBaseText { + text: qsTr("Into") + color: Theme.palette.directColor5 + font.weight: Font.Medium + } + + AccountSelector { + id: accountSelector + model: root.accountsModel + Layout.fillWidth: true + Layout.preferredHeight: 64 + + size: StatusComboBox.Size.Large + selectedAddress: root.selectedAccountAddress + + control.background: SQP.StatusComboboxBackground { + active: accountSelector.control.down || accountSelector.control.hovered + } + + popup.verticalPadding: 0 + popup.width: accountSelector.width + control.contentItem: WalletAccountListItem { + readonly property var account: accountSelector.currentAccount + width: accountSelector.width + height: accountSelector.height + name: !!account ? account.name : "" + address: !!account ? account.address : "" + emoji: !!account ? account.emoji : "" + walletColor: !!account ? account.color : "" + + leftPadding: 0 + rightPadding: 0 + statusListItemTitle.customColor: Theme.palette.directColor1 + enabled: false + } + onCurrentAccountAddressChanged: { + if (root.selectedAccountAddress === "") + selectedAddress = "" // Remove binding to prevent internal binding loop + + root.selectedAccountAddress = currentAccountAddress + } + } + + StatusBaseText { + text: qsTr("On") + color: Theme.palette.directColor5 + font.weight: Font.Medium + } + + StatusComboBox { + id: networkSelector + objectName: "networkSelector" + Layout.fillWidth: true + Layout.preferredHeight: 64 + + readonly property ModelEntry singleSelectionItem: ModelEntry { + sourceModel: root.flatNetworksModel + key: "chainId" + value: root.selectedNetworkChainId ?? -1 + } + + model: root.flatNetworksModel + + control.background: SQP.StatusComboboxBackground { + active: networkSelector.control.down || networkSelector.control.hovered + } + + control.contentItem: StatusListItem { + readonly property var network: networkSelector.singleSelectionItem.item + width: parent.width + title: network.chainName + asset.height: 36 + asset.width: 36 + asset.isImage: true + asset.name: Theme.svg(network.iconUrl) + subTitle: qsTr("Only") + leftPadding: 0 + rightPadding: 0 + statusListItemTitle.customColor: Theme.palette.directColor1 + bgColor: "transparent" + enabled: false + } + + popup.verticalPadding: 0 + delegate: StatusListItem { + required property var model + width: parent.width + title: model.chainName + asset.height: 36 + asset.width: 36 + asset.isImage: true + asset.name: Theme.svg(model.iconUrl) + subTitle: qsTr("Only") + + onClicked: { + root.selectedNetworkChainId = model.chainId + networkSelector.popup.close() + } + } + } + } +} diff --git a/ui/app/AppLayouts/Chat/popups/qmldir b/ui/app/AppLayouts/Chat/popups/qmldir index 83c4f4d9693..7a73d227cb7 100644 --- a/ui/app/AppLayouts/Chat/popups/qmldir +++ b/ui/app/AppLayouts/Chat/popups/qmldir @@ -1 +1,2 @@ PinnedMessagesPopup 1.0 PinnedMessagesPopup.qml +PaymentRequestModal 1.0 PaymentRequestModal.qml diff --git a/ui/app/AppLayouts/Chat/views/ChatColumnView.qml b/ui/app/AppLayouts/Chat/views/ChatColumnView.qml index 9f9c7394b51..d846f7d4dde 100644 --- a/ui/app/AppLayouts/Chat/views/ChatColumnView.qml +++ b/ui/app/AppLayouts/Chat/views/ChatColumnView.qml @@ -25,6 +25,7 @@ import AppLayouts.Communities.popups 1.0 import AppLayouts.Communities.panels 1.0 import AppLayouts.Profile.stores 1.0 as ProfileStores import AppLayouts.Chat.stores 1.0 as ChatStores +import AppLayouts.Wallet.stores 1.0 as WalletStore import "../helpers" import "../controls" @@ -46,6 +47,7 @@ Item { property ProfileStores.ContactsStore contactsStore property var emojiPopup property var stickersPopup + property bool areTestNetworksEnabled property string activeChatId: parentModule && parentModule.activeItem.id property int chatsCount: parentModule && parentModule.model ? parentModule.model.count : 0 @@ -55,6 +57,7 @@ Item { property var viewAndPostHoldingsModel property bool amISectionAdmin: false property bool sendViaPersonalChatEnabled + property bool paymentRequestFeatureEnabled signal openStickerPackPopup(string stickerPackId) @@ -335,6 +338,8 @@ Item { emojiPopup: root.emojiPopup stickersPopup: root.stickersPopup chatType: root.activeChatType + areTestNetworksEnabled: root.areTestNetworksEnabled + paymentRequestFeatureEnabled: root.paymentRequestFeatureEnabled textInput.onTextChanged: { if (!!d.activeChatContentModule && textInput.text !== d.activeChatContentModule.inputAreaModule.preservedProperties.text) { @@ -379,6 +384,7 @@ Item { chatInput.setText("") chatInput.textInput.textFormat = TextEdit.PlainText; chatInput.textInput.textFormat = TextEdit.RichText; + d.activeChatContentModule.inputAreaModule.removeAllPaymentRequestPreviewData() } } @@ -403,6 +409,7 @@ Item { d.activeChatContentModule.inputAreaModule.setLinkPreviewEnabledForCurrentMessage(false) } onDismissLinkPreview: (index) => d.activeChatContentModule.inputAreaModule.removeLinkPreviewData(index) + onOpenPaymentRequestModal: () => Global.openPaymentRequestModalRequested(d.activeChatContentModule.inputAreaModule.addPaymentRequest) onRemovePaymentRequestPreview: (index) => d.activeChatContentModule.inputAreaModule.removePaymentRequestPreviewData(index) } diff --git a/ui/app/AppLayouts/Chat/views/ChatView.qml b/ui/app/AppLayouts/Chat/views/ChatView.qml index ada68577138..4c12538c1ec 100644 --- a/ui/app/AppLayouts/Chat/views/ChatView.qml +++ b/ui/app/AppLayouts/Chat/views/ChatView.qml @@ -54,6 +54,7 @@ StatusSectionLayout { required property var sendModalPopup property var sectionItemModel property int joinedMembersCount + property bool areTestNetworksEnabled property var emojiPopup property var stickersPopup @@ -80,6 +81,7 @@ StatusSectionLayout { property var collectiblesModel property bool sendViaPersonalChatEnabled + property bool paymentRequestFeatureEnabled readonly property bool contentLocked: { if (!rootStore.chatCommunitySectionModule.isCommunity()) { @@ -276,6 +278,7 @@ StatusSectionLayout { sharedRootStore: root.sharedRootStore utilsStore: root.utilsStore rootStore: root.rootStore + areTestNetworksEnabled: root.areTestNetworksEnabled createChatPropertiesStore: root.createChatPropertiesStore contactsStore: root.contactsStore stickersLoaded: root.stickersLoaded @@ -285,6 +288,7 @@ StatusSectionLayout { canPost: !root.rootStore.chatCommunitySectionModule.isCommunity() || root.canPost amISectionAdmin: root.amISectionAdmin sendViaPersonalChatEnabled: root.sendViaPersonalChatEnabled + paymentRequestFeatureEnabled: root.paymentRequestFeatureEnabled onOpenStickerPackPopup: { Global.openPopup(statusStickerPackClickPopup, {packId: stickerPackId, store: root.stickersPopup.store} ) } diff --git a/ui/app/AppLayouts/Chat/views/CreateChatView.qml b/ui/app/AppLayouts/Chat/views/CreateChatView.qml index e3522b1c9dc..c7f93193376 100644 --- a/ui/app/AppLayouts/Chat/views/CreateChatView.qml +++ b/ui/app/AppLayouts/Chat/views/CreateChatView.qml @@ -169,6 +169,7 @@ Page { closeGifPopupAfterSelection: true usersModel: membersSelector.model sharedStore: root.sharedRootStore + paymentRequestFeatureEnabled: false onStickerSelected: { root.createChatPropertiesStore.createChatStickerHashId = hashId; root.createChatPropertiesStore.createChatStickerPackId = packId; diff --git a/ui/app/AppLayouts/Wallet/stores/WalletAssetsStore.qml b/ui/app/AppLayouts/Wallet/stores/WalletAssetsStore.qml index e679f3f2414..9a18cf0d227 100644 --- a/ui/app/AppLayouts/Wallet/stores/WalletAssetsStore.qml +++ b/ui/app/AppLayouts/Wallet/stores/WalletAssetsStore.qml @@ -44,9 +44,10 @@ QtObject { false, Constants.ephemeralNotificationType.success, "") } - /* PRIVATE: This model renames the role "key" to "tokensKey" in TokensBySymbolModel so that + /* This model renames the role "key" to "tokensKey" in TokensBySymbolModel so that it can be easily joined with the Account Assets model */ - readonly property var _renamedTokensBySymbolModel: RolesRenamingModel { + readonly property var renamedTokensBySymbolModel: RolesRenamingModel { + objectName: "renamedTokensBySymbolModel" sourceModel: walletTokensStore.plainTokensBySymbolModel mapping: [ RoleRename { @@ -87,7 +88,7 @@ QtObject { /* PRIVATE: This model joins the "Tokens By Symbol Model" and "Communities Model" by communityId */ property LeftJoinModel _jointTokensBySymbolModel: LeftJoinModel { - leftModel: _renamedTokensBySymbolModel + leftModel: renamedTokensBySymbolModel rightModel: _renamedCommunitiesModel joinRole: "communityId" } diff --git a/ui/app/AppLayouts/stores/FeatureFlagsStore.qml b/ui/app/AppLayouts/stores/FeatureFlagsStore.qml index 02a4f6e589e..af87bdcaa95 100644 --- a/ui/app/AppLayouts/stores/FeatureFlagsStore.qml +++ b/ui/app/AppLayouts/stores/FeatureFlagsStore.qml @@ -5,4 +5,5 @@ QtObject { property bool dappsEnabled property bool swapEnabled property bool sendViaPersonalChatEnabled + property bool paymentRequestEnabled } diff --git a/ui/app/mainui/AppMain.qml b/ui/app/mainui/AppMain.qml index 1e8bfbb00ab..e219a4c788a 100644 --- a/ui/app/mainui/AppMain.qml +++ b/ui/app/mainui/AppMain.qml @@ -99,6 +99,7 @@ Item { dappsEnabled: featureFlags ? featureFlags.dappsEnabled : false swapEnabled: featureFlags ? featureFlags.swapEnabled : false sendViaPersonalChatEnabled: featureFlags ? featureFlags.sendViaPersonalChatEnabled : false + paymentRequestEnabled: featureFlags ? featureFlags.paymentRequestEnabled : false } required property bool isCentralizedMetricsEnabled @@ -1609,6 +1610,8 @@ Item { emojiPopup: statusEmojiPopup.item stickersPopup: statusStickersPopupLoader.item sendViaPersonalChatEnabled: featureFlagsStore.sendViaPersonalChatEnabled && appMain.networkConnectionStore.sendBuyBridgeEnabled + areTestNetworksEnabled: appMain.rootStore.profileSectionStore.walletStore.areTestNetworksEnabled + paymentRequestFeatureEnabled: featureFlagsStore.paymentRequestEnabled mutualContactsModel: contactsModelAdaptor.mutualContacts @@ -1768,6 +1771,7 @@ Item { stickersPopup: statusStickersPopupLoader.item sectionItemModel: model createChatPropertiesStore: appMain.createChatPropertiesStore + areTestNetworksEnabled: appMain.rootStore.profileSectionStore.walletStore.areTestNetworksEnabled communitiesStore: appMain.communitiesStore communitySettingsDisabled: !chatLayoutComponent.isManageCommunityEnabledInAdvanced && (production && appMain.rootStore.profileSectionStore.walletStore.areTestNetworksEnabled) @@ -1788,6 +1792,7 @@ Item { transactionStore: appMain.transactionStore walletAssetsStore: appMain.walletAssetsStore currencyStore: appMain.currencyStore + paymentRequestFeatureEnabled: featureFlagsStore.paymentRequestEnabled mutualContactsModel: contactsModelAdaptor.mutualContacts diff --git a/ui/app/mainui/Popups.qml b/ui/app/mainui/Popups.qml index 03761c786d6..618d7ca4e30 100644 --- a/ui/app/mainui/Popups.qml +++ b/ui/app/mainui/Popups.qml @@ -22,6 +22,7 @@ import AppLayouts.Communities.helpers 1.0 import AppLayouts.Wallet.popups.swap 1.0 import AppLayouts.Wallet.popups.buy 1.0 import AppLayouts.Wallet.popups 1.0 +import AppLayouts.Wallet.adaptors 1.0 import AppLayouts.Communities.stores 1.0 import AppLayouts.Profile.helpers 1.0 @@ -108,6 +109,7 @@ QtObject { Global.openSwapModalRequested.connect(openSwapModal) Global.openBuyCryptoModalRequested.connect(openBuyCryptoModal) Global.privacyPolicyRequested.connect(() => openPopup(privacyPolicyPopupComponent)) + Global.openPaymentRequestModalRequested.connect(openPaymentRequestModal) } property var currentPopup @@ -408,6 +410,10 @@ QtObject { }) } + function openPaymentRequestModal(callback) { + openPopup(paymentRequestModalComponent, {callback: callback}) + } + readonly property list _components: [ Component { id: removeContactConfirmationDialog @@ -1290,6 +1296,35 @@ QtObject { standardButtons: Dialog.Ok destroyOnClose: true } + }, + Component { + id: paymentRequestModalComponent + PaymentRequestModal { + id: paymentRequestModal + readonly property var tokenAdaptor: TokenSelectorViewAdaptor { + assetsModel: null + flatNetworksModel: WalletStores.RootStore.filteredFlatModel + currentCurrency: root.currencyStore.currentCurrency + plainTokensBySymbolModel: WalletStores.RootStore.tokensStore.plainTokensBySymbolModel + enabledChainIds: [paymentRequestModal.selectedNetworkChainId] + showAllTokens: true + } + property var callback: null + currentCurrency: root.currencyStore.currentCurrency + formatCurrencyAmount: root.currencyStore.formatCurrencyAmount + flatNetworksModel: WalletStores.RootStore.filteredFlatModel + accountsModel: WalletStores.RootStore.nonWatchAccounts + assetsModel: tokenAdaptor.outputAssetsModel + + onAccepted: { + if (!callback) { + console.error("No callback set for Payment Request") + return + } + callback(selectedAccountAddress, amount, selectedTokenKey, selectedNetworkChainId) + } + destroyOnClose: true + } } ] } diff --git a/ui/imports/shared/popups/send/views/AmountToSend.qml b/ui/imports/shared/popups/send/views/AmountToSend.qml index 5c62aab9023..eb5b9b88fae 100644 --- a/ui/imports/shared/popups/send/views/AmountToSend.qml +++ b/ui/imports/shared/popups/send/views/AmountToSend.qml @@ -11,6 +11,7 @@ import StatusQ.Validators 0.1 import utils 1.0 import shared.controls 1.0 +import shared.panels 1.0 Control { id: root @@ -284,7 +285,7 @@ Control { Layout.fillWidth: true Layout.preferredHeight: 1 Layout.bottomMargin: 12 - color: Theme.palette.baseColor2 + color: Theme.palette.separator visible: root.dividerVisible } diff --git a/ui/imports/shared/status/StatusChatInput.qml b/ui/imports/shared/status/StatusChatInput.qml index e2b05dfa432..70788e04fea 100644 --- a/ui/imports/shared/status/StatusChatInput.qml +++ b/ui/imports/shared/status/StatusChatInput.qml @@ -15,11 +15,13 @@ import mainui 1.0 //TODO remove this dependency import AppLayouts.Chat.panels 1.0 +import AppLayouts.Chat.popups 1.0 import AppLayouts.Chat.stores 1.0 as ChatStores import StatusQ 0.1 import StatusQ.Core 0.1 import StatusQ.Core.Theme 0.1 +import StatusQ.Popups 0.1 import StatusQ.Core.Utils 0.1 as StatusQUtils import StatusQ.Components 0.1 import StatusQ.Controls 0.1 as StatusQ @@ -37,6 +39,7 @@ Rectangle { signal disableLinkPreview() signal dismissLinkPreviewSettings() signal dismissLinkPreview(int index) + signal openPaymentRequestModal() signal removePaymentRequestPreview(int index) property var usersModel @@ -46,6 +49,8 @@ Rectangle { property var stickersPopup: null // Use this to only enable the Connections only when this Input opens the Emoji popup property bool closeGifPopupAfterSelection: true + property bool areTestNetworksEnabled + property bool paymentRequestFeatureEnabled: false property bool emojiEvent: false property bool isColonPressed: false @@ -158,6 +163,8 @@ Rectangle { property bool emojiPopupOpened: false property bool stickersPopupOpened: false + property var imageDialog: null + // common popups are emoji, jif and stickers // Put controlWidth as argument with default value for binding function getCommonPopupRelativePosition(popup, popupParent, controlWidth = control.width) { @@ -359,7 +366,7 @@ Rectangle { property var mentionsPos: [] function isUploadFilePressed(event) { - return (event.key === Qt.Key_U) && (event.modifiers & Qt.ControlModifier) && imageBtn.visible && !imageBtn.highlighted + return (event.key === Qt.Key_U) && (event.modifiers & Qt.ControlModifier) && !d.imageDialog } function checkTextInsert() { @@ -539,7 +546,7 @@ Rectangle { // ⌘⇧U if (isUploadFilePressed(event)) { - imageBtn.clicked(null) + openImageDialog() event.accepted = true } @@ -953,6 +960,11 @@ Rectangle { messageInputField.forceActiveFocus(); } + function openImageDialog() { + d.imageDialog = imageDialogComponent.createObject(control) + d.imageDialog.open() + } + DropAreaPanel { enabled: control.visible && control.enabled parent: Overlay.overlay @@ -989,13 +1001,45 @@ Rectangle { qsTr("Image files (%1)").arg(UrlUtils.validImageNameFilters) ] onAccepted: { - imageBtn.highlighted = false validateImagesAndShowImageArea(fileUrls) messageInputField.forceActiveFocus() + destroy() } - onRejected: { - imageBtn.highlighted = false + onRejected: destroy() + Component.onDestruction: d.imageDialog = null + } + } + + Component { + id: chatCommandMenuComponent + + StatusMenu { + id: chatCommandMenu + StatusAction { + text: qsTr("Add image") + icon.name: "image" + onTriggered: control.openImageDialog() + } + + MouseArea { + implicitWidth: paymentRequestMenuItem.width + implicitHeight: paymentRequestMenuItem.height + hoverEnabled: true + visible: control.paymentRequestFeatureEnabled + StatusMenuItem { + id: paymentRequestMenuItem + text: parent.containsMouse && !enabled ? qsTr("Not available in Testnet mode") : qsTr("Add payment request") + icon.name: "wallet" + icon.color: enabled ? Theme.palette.primaryColor1 : Theme.palette.baseColor1 + enabled: !control.areTestNetworksEnabled + onTriggered: { + control.openPaymentRequestModal() + chatCommandMenu.close() + } + } } + + closeHandler: () => commandBtn.highlighted = false } } @@ -1072,18 +1116,19 @@ Rectangle { spacing: 4 StatusQ.StatusFlatRoundButton { - id: imageBtn + id: commandBtn Layout.preferredWidth: 32 Layout.preferredHeight: 32 Layout.alignment: Qt.AlignBottom Layout.bottomMargin: 4 - icon.name: "image" + icon.name: "chat-commands" type: StatusQ.StatusFlatRoundButton.Type.Tertiary visible: !isEdit onClicked: { highlighted = true - const popup = imageDialogComponent.createObject(control) - popup.open() + let menu = chatCommandMenuComponent.createObject(commandBtn) + menu.y = -menu.height // Show above button + menu.open() } } diff --git a/ui/imports/utils/Global.qml b/ui/imports/utils/Global.qml index ad82200e974..3798b00b690 100644 --- a/ui/imports/utils/Global.qml +++ b/ui/imports/utils/Global.qml @@ -90,6 +90,8 @@ QtObject { signal privacyPolicyRequested() + signal openPaymentRequestModalRequested(var callback) + // Swap signal openSwapModalRequested(var formDataParams)