From 8b44e686f4a0597863ee9f3c19185721c13897af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Cie=C5=9Blak?= Date: Tue, 6 Jun 2023 17:32:53 +0200 Subject: [PATCH] feat(CommunityAirdrops): Fees popup with multiple entries for airdrops Closes: #10484 --- storybook/PagesModel.qml | 4 + .../SignMultiTokenTransactionsPopupPage.qml | 133 +++++++++++++ .../src/StatusQ/Core/Utils/ModelUtils.qml | 4 + .../CommunityAirdropsSettingsPanel.qml | 13 +- .../SignMultiTokenTransactionsPopup.qml | 179 ++++++++++++++++++ .../AppLayouts/Chat/popups/community/qmldir | 3 +- .../Chat/views/CommunitySettingsView.qml | 12 ++ .../communities/CommunityNewAirdropView.qml | 128 ++++++++++++- .../shared/stores/CommunityTokensStore.qml | 43 +++-- 9 files changed, 498 insertions(+), 21 deletions(-) create mode 100644 storybook/pages/SignMultiTokenTransactionsPopupPage.qml create mode 100644 ui/app/AppLayouts/Chat/popups/community/SignMultiTokenTransactionsPopup.qml diff --git a/storybook/PagesModel.qml b/storybook/PagesModel.qml index 2fc8932fa0..dfd9be9418 100644 --- a/storybook/PagesModel.qml +++ b/storybook/PagesModel.qml @@ -193,6 +193,10 @@ ListModel { title: "SignTokenTransactionsPopup" section: "Popups" } + ListElement { + title: "SignMultiTokenTransactionsPopup" + section: "Popups" + } ListElement { title: "RemotelyDestructPopup" section: "Popups" diff --git a/storybook/pages/SignMultiTokenTransactionsPopupPage.qml b/storybook/pages/SignMultiTokenTransactionsPopupPage.qml new file mode 100644 index 0000000000..b9589c2aca --- /dev/null +++ b/storybook/pages/SignMultiTokenTransactionsPopupPage.qml @@ -0,0 +1,133 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import Storybook 1.0 + +import AppLayouts.Chat.popups.community 1.0 + + +SplitView { + Logs { id: logs } + + SplitView { + orientation: Qt.Vertical + SplitView.fillWidth: true + + Pane { + id: pane + + SplitView.fillWidth: true + SplitView.fillHeight: true + + PopupBackground { + anchors.fill: parent + } + + Button { + anchors.centerIn: parent + text: "Reopen" + + onClicked: dialog.open() + } + + SignMultiTokenTransactionsPopup { + id: dialog + + model: ListModel { + id: feesModel + + ListElement { + account: "My Account 1" + network: "Optimism" + symbol: "TAT" + amount: 2 + feeText: "0.0015 ($75.43)" + } + ListElement { + account: "My Account 2" + network: "Arbitrum" + symbol: "SNT" + amount: 34 + feeText: "0.0085 ETH ($175.43)" + } + } + + closePolicy: Popup.NoAutoClose + visible: true + modal: false + destroyOnClose: false + + title: `Sign transaction - Airdrop ${model.count} token(s) to 32 recipients` + + isFeeLoading: loadingSwitch.checked + showSummary: showSummarySwitch.checked + + errorText: errorTextField.text + totalFeeText: "0.01 ETH ($265.43)" + + onSignTransactionClicked: logs.logEvent("SignTokenTransactionsPopup::onSignTransactionClicked") + onCancelClicked: logs.logEvent("SignTokenTransactionsPopup::onCancelClicked") + } + } + + LogsAndControlsPanel { + id: logsAndControlsPanel + + SplitView.minimumHeight: 100 + SplitView.preferredHeight: 150 + + logsView.logText: logs.logText + } + } + + Pane { + SplitView.minimumWidth: 300 + SplitView.preferredWidth: 300 + + ColumnLayout { + anchors.fill: parent + + Label { + Layout.fillWidth: true + + text: "Error text" + } + + TextField { + id: errorTextField + + Layout.fillWidth: true + + text: "" + } + + SpinBox { + id: recipientsCountSpinBox + + from: 1 + to: 1000 + } + + Switch { + id: loadingSwitch + + text: "Is fee loading" + checked: false + } + + Switch { + id: showSummarySwitch + + text: "Is summary visible" + checked: true + } + + Item { + Layout.fillHeight: true + } + } + } +} + + diff --git a/ui/StatusQ/src/StatusQ/Core/Utils/ModelUtils.qml b/ui/StatusQ/src/StatusQ/Core/Utils/ModelUtils.qml index 290d640e64..b460f83302 100644 --- a/ui/StatusQ/src/StatusQ/Core/Utils/ModelUtils.qml +++ b/ui/StatusQ/src/StatusQ/Core/Utils/ModelUtils.qml @@ -117,4 +117,8 @@ QtObject { return true } + + function roleNames(model) { + return Internal.ModelUtils.roleNames(model) + } } diff --git a/ui/app/AppLayouts/Chat/panels/communities/CommunityAirdropsSettingsPanel.qml b/ui/app/AppLayouts/Chat/panels/communities/CommunityAirdropsSettingsPanel.qml index 77ac24f80c..3daf46f16a 100644 --- a/ui/app/AppLayouts/Chat/panels/communities/CommunityAirdropsSettingsPanel.qml +++ b/ui/app/AppLayouts/Chat/panels/communities/CommunityAirdropsSettingsPanel.qml @@ -19,11 +19,17 @@ SettingsPageLayout { required property var membersModel + // JS object specifing fees for the airdrop operation, should be set to + // provide response to airdropFeesRequested signal. + // Refer CommunityNewAirdropView::airdropFees for details. + property var airdropFees: null + property int viewWidth: 560 // by design signal airdropClicked(var airdropTokens, var addresses, var membersPubKeys) - signal navigateToMintTokenSettings + signal airdropFeesRequested(var contractKeysAndAmounts, var addresses) + signal navigateToMintTokenSettings function navigateBack() { stackManager.pop(StackView.Immediate) @@ -109,6 +115,10 @@ SettingsPageLayout { collectiblesModel: root.collectiblesModel membersModel: root.membersModel + Binding on airdropFees { + value: root.airdropFees + } + onAirdropClicked: { root.airdropClicked(airdropTokens, addresses, membersPubKeys) stackManager.clear(d.welcomeViewState, StackView.Immediate) @@ -118,6 +128,7 @@ SettingsPageLayout { Component.onCompleted: { d.selectToken.connect(view.selectToken) d.addAddresses.connect(view.addAddresses) + airdropFeesRequested.connect(root.airdropFeesRequested) } } } diff --git a/ui/app/AppLayouts/Chat/popups/community/SignMultiTokenTransactionsPopup.qml b/ui/app/AppLayouts/Chat/popups/community/SignMultiTokenTransactionsPopup.qml new file mode 100644 index 0000000000..cb3a20d917 --- /dev/null +++ b/ui/app/AppLayouts/Chat/popups/community/SignMultiTokenTransactionsPopup.qml @@ -0,0 +1,179 @@ +import QtQuick 2.15 +import QtQuick.Layouts 1.15 +import QtQml.Models 2.15 + +import StatusQ.Core 0.1 +import StatusQ.Controls 0.1 +import StatusQ.Components 0.1 +import StatusQ.Popups.Dialog 0.1 +import StatusQ.Core.Theme 0.1 + +import utils 1.0 + +StatusDialog { + id: root + + // account, amount, symbol, network, feeText + property alias model: repeater.model + property alias showSummary: summaryRow.visible + property alias errorText: errorTxt.text + property alias totalFeeText: totalFeeText.text + + property bool isFeeLoading + + signal signTransactionClicked() + signal cancelClicked() + + QtObject { + id: d + + property int minTextWidth: 50 + } + + implicitWidth: 600 // by design + topPadding: 2 * Style.current.padding // by design + bottomPadding: topPadding + + contentItem: ColumnLayout { + id: column + + spacing: Style.current.padding + + Repeater { + id: repeater + + Item { + Layout.fillWidth: true + + implicitHeight: delegateColumn.implicitHeight + + ColumnLayout { + id: delegateColumn + + width: parent.width + + RowLayout { + Layout.fillWidth: true + + StatusBaseText { + Layout.fillWidth: true + + text: qsTr("Airdropping %1 %2 on %3") + .arg(model.amount).arg(model.symbol) + .arg(model.network) + + font.pixelSize: Style.current.primaryTextFontSize + elide: Text.ElideMiddle + } + + StatusDotsLoadingIndicator { + visible: root.isFeeLoading + + Layout.rightMargin: Style.current.padding + } + + StatusBaseText { + text: model.feeText + + font.pixelSize: Style.current.primaryTextFontSize + elide: Text.ElideMiddle + + color: Theme.palette.baseColor1 + + visible: !root.isFeeLoading + } + } + + StatusBaseText { + Layout.fillWidth: true + + text: qsTr("via %1").arg(model.account) + horizontalAlignment: Text.AlignLeft + font.pixelSize: Style.current.primaryTextFontSize + elide: Text.ElideMiddle + + color: Theme.palette.baseColor1 + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + + color: Theme.palette.baseColor2 + } + + RowLayout { + id: summaryRow + + Layout.fillHeight: false + Layout.fillWidth: true + + Layout.topMargin: Style.current.halfPadding + Layout.bottomMargin: -Style.current.halfPadding + + StatusBaseText { + Layout.fillWidth: true + + text: qsTr("Total") + + font.pixelSize: Style.current.primaryTextFontSize + elide: Text.ElideMiddle + } + + StatusDotsLoadingIndicator { + visible: root.isFeeLoading + + Layout.rightMargin: Style.current.padding + } + + StatusBaseText { + id: totalFeeText + + font.pixelSize: Style.current.primaryTextFontSize + visible: !root.isFeeLoading + } + } + + StatusBaseText { + id: errorTxt + + Layout.topMargin: Style.current.halfPadding + Layout.bottomMargin: -Style.current.halfPadding + Layout.fillWidth: true + + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignRight + font.pixelSize: Style.current.primaryTextFontSize + color: Theme.palette.dangerColor1 + + text: root.errorText + visible: root.errorText !== "" + } + } + + footer: StatusDialogFooter { + spacing: Style.current.padding + rightButtons: ObjectModel { + StatusButton { + text: qsTr("Cancel") + type: StatusBaseButton.Type.Danger + onClicked: { + root.cancelClicked() + root.close() + } + } + StatusButton { + enabled: root.errorText === "" && !root.isFeeLoading + icon.name: "password" + text: qsTr("Sign transaction") + onClicked: { + root.signTransactionClicked() + root.close() + } + } + } + } +} diff --git a/ui/app/AppLayouts/Chat/popups/community/qmldir b/ui/app/AppLayouts/Chat/popups/community/qmldir index d4081461cb..5f7628a046 100644 --- a/ui/app/AppLayouts/Chat/popups/community/qmldir +++ b/ui/app/AppLayouts/Chat/popups/community/qmldir @@ -1,6 +1,7 @@ AlertPopup 1.0 AlertPopup.qml BurnTokensPopup 1.0 BurnTokensPopup.qml -CreateChannelPopup 1.0 CreateChannelPopup.qml CommunityTokenPermissionsPopup 1.0 CommunityTokenPermissionsPopup.qml +CreateChannelPopup 1.0 CreateChannelPopup.qml RemotelyDestructPopup 1.0 RemotelyDestructPopup.qml +SignMultiTokenTransactionsPopup 1.0 SignMultiTokenTransactionsPopup.qml SignTokenTransactionsPopup 1.0 SignTokenTransactionsPopup.qml diff --git a/ui/app/AppLayouts/Chat/views/CommunitySettingsView.qml b/ui/app/AppLayouts/Chat/views/CommunitySettingsView.qml index bc01506765..812c3b98c3 100644 --- a/ui/app/AppLayouts/Chat/views/CommunitySettingsView.qml +++ b/ui/app/AppLayouts/Chat/views/CommunitySettingsView.qml @@ -447,6 +447,10 @@ StatusSectionLayout { onAirdropClicked: communityTokensStore.airdrop(root.community.id, airdropTokens, addresses) onNavigateToMintTokenSettings: root.goTo(Constants.CommunitySettingsSections.MintTokens) + onAirdropFeesRequested: + communityTokensStore.computeAirdropFee( + root.community.id, contractKeysAndAmounts, addresses) + Connections { target: mintPanel @@ -461,6 +465,14 @@ StatusSectionLayout { airdropPanel.addAddresses(addresses) } } + + Connections { + target: rootStore.communityTokensStore + + function onAirdropFeeUpdated(airdropFees) { + airdropPanel.airdropFees = airdropFees + } + } } onCurrentIndexChanged: root.backButtonName = centerPanelContentLoader.item.children[d.currentIndex].previousPageName diff --git a/ui/app/AppLayouts/Chat/views/communities/CommunityNewAirdropView.qml b/ui/app/AppLayouts/Chat/views/communities/CommunityNewAirdropView.qml index dde75d5e87..92c0795425 100644 --- a/ui/app/AppLayouts/Chat/views/communities/CommunityNewAirdropView.qml +++ b/ui/app/AppLayouts/Chat/views/communities/CommunityNewAirdropView.qml @@ -9,9 +9,10 @@ import StatusQ.Core.Utils 0.1 import utils 1.0 import shared.panels 1.0 +import AppLayouts.Chat.controls.community 1.0 import AppLayouts.Chat.helpers 1.0 import AppLayouts.Chat.panels.communities 1.0 -import AppLayouts.Chat.controls.community 1.0 +import AppLayouts.Chat.popups.community 1.0 import SortFilterProxyModel 0.2 @@ -26,6 +27,23 @@ StatusScrollView { // Community members model: required property var membersModel + // JS object specifing fees for the airdrop operation, should be set to + // provide response to airdropFeesRequested signal. + // + // The expected structure is as follows: + // { + // fees: [{ + // ethFee: {CurrencyAmount JSON}, + // fiatFee: {CurrencyAmount JSON}, + // contractUniqueKey: string, + // errorCode: ComputeFeeErrorCode (int) + // }], + // totalEthFee: {CurrencyAmount JSON}, + // totalFiatFee: {CurrencyAmount JSON}, + // errorCode: ComputeFeeErrorCode (int) + // } + property var airdropFees: null + property int viewWidth: 560 // by design readonly property var selectedHoldingsModel: ListModel {} @@ -35,6 +53,9 @@ StatusScrollView { airdropRecipientsSelector.valid signal airdropClicked(var airdropTokens, var addresses, var membersPubKeys) + + signal airdropFeesRequested(var contractKeysAndAmounts, var addresses) + signal navigateToMintTokenSettings function selectToken(key, amount, type) { @@ -81,6 +102,7 @@ StatusScrollView { supply: modelItem.supply, infiniteSupply: modelItem.infiniteSupply, contractUniqueKey: modelItem.contractUniqueKey, + accountName: modelItem.accountName } } } @@ -401,6 +423,64 @@ StatusScrollView { enabled: root.isFullyFilled onClicked: { + feesPopup.open() + } + } + + SignMultiTokenTransactionsPopup { + id: feesPopup + + destroyOnClose: false + + model: ListModel { + id: feesModel + } + + isFeeLoading: root.airdropFees === null || + (root.airdropFees.errorCode !== Constants.ComputeFeeErrorCode.Success && + root.airdropFees.errorCode !== Constants.ComputeFeeErrorCode.Balance) + + onOpened: { + const title1 = qsTr("Sign transaction - Airdrop %n token(s)", "", + selectedHoldingsModel.rowCount()) + const title2 = qsTr("to %n recipient(s)", "", + addresses.count + airdropRecipientsSelector.membersModel.count) + + title = `${title1} ${title2}` + + root.airdropFees = null + errorText = "" + feesModel.clear() + + const airdropTokens = ModelUtils.modelToArray( + root.selectedHoldingsModel, + ["contractUniqueKey", "accountName", + "key", "amount", "tokenText", + "networkText"]) + + airdropTokens.forEach(entry => { + feesModel.append({ + contractUniqueKey: entry.contractUniqueKey, + key: entry.key, + amount: entry.amount, + account: entry.accountName, + symbol: entry.key, + network: entry.networkText, + feeText: "" + }) + }) + + const contractKeysAndAmounts = airdropTokens.map(item => ({ + amount: item.amount, + contractUniqueKey: item.contractUniqueKey + })) + const addresses_ = ModelUtils.modelToArray( + addresses, ["address"]).map(e => e.address) + + airdropFeesRequested(contractKeysAndAmounts, addresses_) + } + + onSignTransactionClicked: { const airdropTokens = ModelUtils.modelToArray( root.selectedHoldingsModel, ["contractUniqueKey", "amount"]) @@ -412,6 +492,52 @@ StatusScrollView { root.airdropClicked(airdropTokens, addresses_, pubKeys) } + + Connections { + target: root + + function onAirdropFeesChanged() { + if (root.airdropFees === null) + return + + const fees = root.airdropFees.fees + const errorCode = root.airdropFees.errorCode + + function buildFeeString(ethFee, fiatFee) { + return `${LocaleUtils.currencyAmountToLocaleString(ethFee)} (${LocaleUtils.currencyAmountToLocaleString(fiatFee)})` + } + + if (errorCode === Constants.ComputeFeeErrorCode.Infura) { + feesPopup.errorText = qsTr("Infura error") + return + } + + if (errorCode === Constants.ComputeFeeErrorCode.Success || + errorCode === Constants.ComputeFeeErrorCode.Balance) { + fees.forEach(fee => { + const idx = ModelUtils.indexOf( + feesModel, "contractUniqueKey", + fee.contractUniqueKey) + + feesPopup.model.set(idx, { + feeText: buildFeeString(fee.ethFee, fee.fiatFee) + }) + }) + + feesPopup.totalFeeText = buildFeeString( + root.airdropFees.totalEthFee, + root.airdropFees.totalFiatFee) + + if (errorCode === Constants.ComputeFeeErrorCode.Balance) { + feesPopup.errorText = qsTr("Not enough funds to make transaction") + } + + return + } + + feesPopup.errorText = qsTr("Unknown error") + } + } } } } diff --git a/ui/imports/shared/stores/CommunityTokensStore.qml b/ui/imports/shared/stores/CommunityTokensStore.qml index 73b80adf3d..d116169ccb 100644 --- a/ui/imports/shared/stores/CommunityTokensStore.qml +++ b/ui/imports/shared/stores/CommunityTokensStore.qml @@ -15,6 +15,7 @@ QtObject { signal deployFeeUpdated(var ethCurrency, var fiatCurrency, int error) signal selfDestructFeeUpdated(var ethCurrency, var fiatCurrency, int error) + signal airdropFeeUpdated(var airdropFees) signal deploymentStateChanged(string communityId, int status, string url) @@ -51,23 +52,27 @@ QtObject { } readonly property Connections connections: Connections { - target: communityTokensModuleInst - function onDeployFeeUpdated(ethCurrency, fiatCurrency, errorCode) { - root.deployFeeUpdated(ethCurrency, fiatCurrency, errorCode) - } - function onSelfDestructFeeUpdated(ethCurrency, fiatCurrency, errorCode) { - root.selfDestructFeeUpdated(ethCurrency, fiatCurrency, errorCode) - } - function onAirdropFeesUpdated(jsonFees) { - console.log("Fees:", jsonFees) - } + target: communityTokensModuleInst - function onDeploymentStateChanged(communityId, status, url) { - root.deploymentStateChanged(communityId, status, url) - } - function onRemoteDestructStateChanged(communityId, tokenName, status, url) { - root.remoteDestructStateChanged(communityId, tokenName, status, url) - } + function onDeployFeeUpdated(ethCurrency, fiatCurrency, errorCode) { + root.deployFeeUpdated(ethCurrency, fiatCurrency, errorCode) + } + + function onSelfDestructFeeUpdated(ethCurrency, fiatCurrency, errorCode) { + root.selfDestructFeeUpdated(ethCurrency, fiatCurrency, errorCode) + } + + function onAirdropFeesUpdated(jsonFees) { + root.airdropFeeUpdated(JSON.parse(jsonFees)) + } + + function onDeploymentStateChanged(communityId, status, url) { + root.deploymentStateChanged(communityId, status, url) + } + + function onRemoteDestructStateChanged(communityId, tokenName, status, url) { + root.remoteDestructStateChanged(communityId, tokenName, status, url) + } } function computeDeployFee(chainId, accountAddress) { @@ -99,7 +104,9 @@ QtObject { communityTokensModuleInst.airdropCollectibles(communityId, JSON.stringify(airdropTokens), JSON.stringify(addresses)) } - function computeAirdropFee(communityId, airdropTokens, addresses) { - communityTokensModuleInst.computeAirdropCollectiblesFee(communityId, JSON.stringify(airdropTokens), JSON.stringify(addresses)) + function computeAirdropFee(communityId, contractKeysAndAmounts, addresses) { + communityTokensModuleInst.computeAirdropCollectiblesFee( + communityId, JSON.stringify(contractKeysAndAmounts), + JSON.stringify(addresses)) } }