status-desktop/ui/app/AppLayouts/Communities/views/EditAirdropView.qml

716 lines
24 KiB
QML

import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import StatusQ.Components 0.1
import StatusQ.Controls 0.1
import StatusQ.Core 0.1
import StatusQ.Core.Theme 0.1
import StatusQ.Core.Utils 0.1
import utils 1.0
import shared.panels 1.0
import AppLayouts.Communities.controls 1.0
import AppLayouts.Communities.helpers 1.0
import AppLayouts.Communities.panels 1.0
import AppLayouts.Communities.popups 1.0
import SortFilterProxyModel 0.2
StatusScrollView {
id: root
// id, name, image, color, owner properties expected
required property var communityDetails
// Token models:
required property var assetsModel
required property var collectiblesModel
// 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 {}
ModelChangeTracker {
id: holdingsModelTracker
model: selectedHoldingsModel
}
ModelChangeTracker {
id: addressesModelTracker
model: addresses
}
ModelChangeTracker {
id: membersModelTracker
model: selectedMembersModel
}
readonly property bool isFullyFilled: tokensSelector.count > 0 &&
airdropRecipientsSelector.count > 0 &&
airdropRecipientsSelector.valid
signal airdropClicked(var airdropTokens, var addresses, var membersPubKeys)
signal airdropFeesRequested(var contractKeysAndAmounts, var addresses)
signal navigateToMintTokenSettings(bool isAssetType)
function selectToken(key, amount, type) {
if(selectedHoldingsModel)
selectedHoldingsModel.clear()
var tokenModel = null
if(type === Constants.TokenType.ERC20)
tokenModel = root.assetsModel
else if (type === Constants.TokenType.ERC721)
tokenModel = root.collectiblesModel
const modelItem = PermissionsHelpers.getTokenByKey(
tokenModel, key)
const entry = d.prepareEntry(key, amount, type)
entry.valid = true
selectedHoldingsModel.append(entry)
}
function addAddresses(_addresses) {
addresses.addAddresses(_addresses)
}
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)})`
}
d.feesError = ""
d.totalFee = ""
if (errorCode === Constants.ComputeFeeErrorCode.Infura) {
d.feesError = qsTr("Infura error")
return
}
if (errorCode === Constants.ComputeFeeErrorCode.Success ||
errorCode === Constants.ComputeFeeErrorCode.Balance) {
fees.forEach(fee => {
const idx = ModelUtils.indexOf(
feesModel, "contractUniqueKey",
fee.contractUniqueKey)
feesModel.set(idx, {
feeText: buildFeeString(fee.ethFee, fee.fiatFee)
})
})
d.totalFee = buildFeeString(
root.airdropFees.totalEthFee,
root.airdropFees.totalFiatFee)
if (errorCode === Constants.ComputeFeeErrorCode.Balance) {
d.feesError = qsTr("Your account does not have enough ETH to pay the gas fee for this airdrop. Try adding some ETH to your account.")
}
return
}
d.feesError = qsTr("Unknown error")
}
QtObject {
id: d
readonly property int maxAirdropTokens: 5
readonly property int dropdownHorizontalOffset: 4
readonly property int dropdownVerticalOffset: 1
property string feesError
property string totalFee
readonly property bool isFeeLoading:
root.airdropFees === null ||
(root.airdropFees.errorCode !== Constants.ComputeFeeErrorCode.Success &&
root.airdropFees.errorCode !== Constants.ComputeFeeErrorCode.Balance)
readonly property bool showFees: root.selectedHoldingsModel.count > 0
&& airdropRecipientsSelector.valid
&& airdropRecipientsSelector.count > 0
readonly property int totalRevision: holdingsModelTracker.revision
+ addressesModelTracker.revision
+ membersModelTracker.revision
+ (d.showFees ? 1 : 0)
onTotalRevisionChanged: {
Qt.callLater(() => {
if (!d.showFees)
return
d.resetFees()
})
}
function prepareEntry(key, amount, type) {
let tokenModel = null
if(type === Constants.TokenType.ERC20)
tokenModel = root.assetsModel
else if (type === Constants.TokenType.ERC721)
tokenModel = root.collectiblesModel
const modelItem = PermissionsHelpers.getTokenByKey(
tokenModel, key)
return {
key, amount, type,
tokenText: amount + " " + modelItem.name,
tokenImage: modelItem.iconSource,
networkText: modelItem.chainName,
networkImage: Style.svg(modelItem.chainIcon),
supply: modelItem.supply,
infiniteSupply: modelItem.infiniteSupply,
contractUniqueKey: modelItem.contractUniqueKey,
accountName: modelItem.accountName,
symbol: modelItem.symbol
}
}
function rebuildFeesModel() {
feesModel.clear()
const airdropTokens = ModelUtils.modelToArray(
root.selectedHoldingsModel,
["contractUniqueKey", "accountName",
"symbol", "amount", "tokenText",
"networkText"])
airdropTokens.forEach(entry => {
feesModel.append({
contractUniqueKey: entry.contractUniqueKey,
amount: entry.amount * addresses.count,
account: entry.accountName,
symbol: entry.symbol,
network: entry.networkText,
feeText: ""
})
})
}
function requestFees() {
const airdropTokens = ModelUtils.modelToArray(
root.selectedHoldingsModel,
["contractUniqueKey", "amount"])
const contractKeysAndAmounts = airdropTokens.map(item => ({
amount: item.amount,
contractUniqueKey: item.contractUniqueKey
}))
const addressesArray = ModelUtils.modelToArray(
addresses, ["address"]).map(e => e.address)
airdropFeesRequested(contractKeysAndAmounts, addressesArray)
}
function resetFees() {
root.airdropFees = null
d.feesError = ""
d.totalFee = ""
d.rebuildFeesModel()
d.requestFees()
}
}
ListModel {
id: feesModel
}
Instantiator {
id: recipientsCountInstantiator
model: selectedHoldingsModel
property bool infinity: true
property int maximumRecipientsCount
function findRecipientsCount() {
let min = Number.MAX_SAFE_INTEGER
for (let i = 0; i < count; i++) {
const item = objectAt(i)
if (!item || item.infiniteSupply)
continue
min = Math.min(item.supply / item.amount, min)
}
infinity = min === Number.MAX_SAFE_INTEGER
maximumRecipientsCount = infinity ? 0 : min
}
delegate: QtObject {
readonly property int supply: model.supply
readonly property real amount: model.amount
readonly property bool infiniteSupply: model.infiniteSupply
readonly property bool valid:
infiniteSupply || amount * airdropRecipientsSelector.count <= supply
onSupplyChanged: recipientsCountInstantiator.findRecipientsCount()
onAmountChanged: recipientsCountInstantiator.findRecipientsCount()
onInfiniteSupplyChanged: recipientsCountInstantiator.findRecipientsCount()
onValidChanged: model.valid = valid
Component.onCompleted: model.valid = valid
}
onCountChanged: findRecipientsCount()
}
SequenceColumnLayout {
id: mainLayout
width: root.viewWidth
spacing: 0
AirdropTokensSelector {
id: tokensSelector
property int editedIndex: -1
Layout.fillWidth: true
icon: Style.svg("token")
title: qsTr("What")
placeholderText: qsTr("Example: 1 SOCK")
addButton.visible: model.count < d.maxAirdropTokens
model: root.selectedHoldingsModel
HoldingsDropdown {
id: dropdown
communityId: communityDetails.id
assetsModel: root.assetsModel
collectiblesModel: root.collectiblesModel
isENSTab: false
noDataTextForAssets: qsTr("First you need to mint or import an asset before you can perform an airdrop")
noDataTextForCollectibles: qsTr("First you need to mint or import a collectible before you can perform an airdrop")
function getHoldingIndex(key) {
return ModelUtils.indexOf(root.selectedHoldingsModel, "key", key)
}
function prepareUpdateIndex(key) {
const itemIndex = tokensSelector.editedIndex
const existingIndex = getHoldingIndex(key)
if (itemIndex !== -1 && existingIndex !== -1 && itemIndex !== existingIndex) {
const previousKey = root.selectedHoldingsModel.get(itemIndex).key
root.selectedHoldingsModel.remove(existingIndex)
return getHoldingIndex(previousKey)
}
if (itemIndex === -1) {
return existingIndex
}
return itemIndex
}
onOpened: {
usedTokens = ModelUtils.modelToArray(
root.selectedHoldingsModel, ["key", "amount"])
}
onAddAsset: {
const entry = d.prepareEntry(key, amount, Constants.TokenType.ERC20)
entry.valid = true
selectedHoldingsModel.append(entry)
dropdown.close()
}
onAddCollectible: {
const entry = d.prepareEntry(key, amount, Constants.TokenType.ERC721)
entry.valid = true
selectedHoldingsModel.append(entry)
dropdown.close()
}
onUpdateAsset: {
const itemIndex = prepareUpdateIndex(key)
const entry = d.prepareEntry(key, amount, Constants.TokenType.ERC20)
root.selectedHoldingsModel.set(itemIndex, entry)
dropdown.close()
}
onUpdateCollectible: {
const itemIndex = prepareUpdateIndex(key)
const entry = d.prepareEntry(key, amount, Constants.TokenType.ERC721)
root.selectedHoldingsModel.set(itemIndex, entry)
dropdown.close()
}
onRemoveClicked: {
root.selectedHoldingsModel.remove(tokensSelector.editedIndex)
dropdown.close()
}
onNavigateToMintTokenSettings: {
root.navigateToMintTokenSettings(isAssetType)
close()
}
}
addButton.onClicked: {
dropdown.parent = tokensSelector.addButton
dropdown.x = tokensSelector.addButton.width + d.dropdownHorizontalOffset
dropdown.y = 0
dropdown.open()
editedIndex = -1
}
onItemClicked: {
if (mouse.button !== Qt.LeftButton)
return
dropdown.parent = item
dropdown.x = mouse.x + d.dropdownHorizontalOffset
dropdown.y = d.dropdownVerticalOffset
const modelItem = selectedHoldingsModel.get(index)
switch(modelItem.type) {
case HoldingTypes.Type.Asset:
dropdown.assetKey = modelItem.key
dropdown.assetAmount = modelItem.amount
dropdown.setActiveTab(HoldingTypes.Type.Asset)
break
case HoldingTypes.Type.Collectible:
dropdown.collectibleKey = modelItem.key
dropdown.collectibleAmount = modelItem.amount
dropdown.setActiveTab(HoldingTypes.Type.Collectible)
break
default:
console.warn("Unsupported token type.")
}
dropdown.openUpdateFlow()
editedIndex = index
}
}
SequenceColumnLayout.Separator {}
AirdropRecipientsSelector {
id: airdropRecipientsSelector
addressesModel: addresses
infiniteMaxNumberOfRecipients:
recipientsCountInstantiator.infinity
maxNumberOfRecipients:
recipientsCountInstantiator.maximumRecipientsCount
membersModel: SortFilterProxyModel {
id: selectedMembersModel
sourceModel: membersModel
filters: ExpressionFilter {
id: selectedKeysFilter
property var keys: new Set()
expression: keys.has(model.pubKey)
}
}
onRemoveMemberRequested: {
const pubKey = ModelUtils.get(membersModel, index, "pubKey")
selectedKeysFilter.keys.delete(pubKey)
selectedKeysFilter.keys = new Set([...selectedKeysFilter.keys])
}
onAddAddressesRequested: (addresses_) => {
addresses.addAddressesFromString(addresses_)
airdropRecipientsSelector.clearAddressesInput()
airdropRecipientsSelector.positionAddressesListAtEnd()
}
onRemoveAddressRequested: addresses.remove(index)
ListModel {
id: addresses
function addAddresses(_addresses) {
const existing = new Set()
for (let i = 0; i < count; i++)
existing.add(get(i).address)
_addresses.forEach(address => {
if (existing.has(address))
return
const valid = Utils.isValidAddress(address)
append({ valid, address })
})
}
function addAddressesFromString(addressesString) {
const words = addressesString.trim().split(/[\s+,]/)
const wordsNonEmpty = words.filter(word => !!word)
addAddresses(wordsNonEmpty)
}
}
function openPopup(popup) {
popup.parent = addButton
popup.x = addButton.width + d.dropdownHorizontalOffset
popup.y = 0
popup.open()
}
addButton.onClicked: openPopup(recipientTypeSelectionDropdown)
RecipientTypeSelectionDropdown {
id: recipientTypeSelectionDropdown
onEthAddressesSelected: {
airdropRecipientsSelector.showAddressesInputWhenEmpty = true
airdropRecipientsSelector.forceInputFocus()
recipientTypeSelectionDropdown.close()
}
onCommunityMembersSelected: {
recipientTypeSelectionDropdown.close()
membersDropdown.selectedKeys = selectedKeysFilter.keys
const hasSelection = selectedKeysFilter.keys.size !== 0
membersDropdown.mode = hasSelection
? MembersDropdown.Mode.Update
: MembersDropdown.Mode.Add
airdropRecipientsSelector.openPopup(membersDropdown)
}
}
MembersDropdown {
id: membersDropdown
forceButtonDisabled:
mode === MembersDropdown.Mode.Update &&
[...selectedKeys].sort().join() === [...selectedKeysFilter.keys].sort().join()
model: SortFilterProxyModel {
sourceModel: membersModel
filters: [
ExpressionFilter {
enabled: membersDropdown.searchText !== ""
function matchesAlias(name, filter) {
return name.split(" ").some(p => p.startsWith(filter))
}
expression: {
membersDropdown.searchText
const filter = membersDropdown.searchText.toLowerCase()
return matchesAlias(model.alias.toLowerCase(), filter)
|| model.displayName.toLowerCase().includes(filter)
|| model.ensName.toLowerCase().includes(filter)
|| model.localNickname.toLowerCase().includes(filter)
|| model.pubKey.toLowerCase().includes(filter)
}
}
]
}
onBackButtonClicked: {
close()
airdropRecipientsSelector.openPopup(
recipientTypeSelectionDropdown)
}
onAddButtonClicked: {
selectedKeysFilter.keys = selectedKeys
close()
}
}
}
SequenceColumnLayout.Separator {}
StatusGroupBox {
id: feesBox
Layout.fillWidth: true
implicitWidth: 0
title: qsTr("Fees")
icon: Style.svg("gas")
Control {
id: feesControl
width: feesBox.availableWidth
padding: Style.current.padding
verticalPadding: 18
background: Rectangle {
radius: Style.current.radius
color: Theme.palette.statusListItem.backgroundColor
}
contentItem: Loader {
Component {
id: feesPanelComponent
FeesPanel {
width: feesControl.availableWidth
showAccounts: false
totalFeeText: d.totalFee
showSummary: count > 1
isFeeLoading: d.isFeeLoading
model: feesModel
}
}
Component {
id: placeholderComponent
StatusBaseText {
width: feesControl.availableWidth
text: qsTr("Add valid “What” and “To” values to see fees")
font.pixelSize: Style.current.primaryTextFontSize
elide: Text.ElideRight
color: Theme.palette.baseColor1
}
}
sourceComponent: d.showFees ? feesPanelComponent
: placeholderComponent
}
}
}
WarningPanel {
id: notEnoughTokensWarning
Layout.fillWidth: true
Layout.topMargin: Style.current.padding
text: qsTr("Not enough tokens to send to all recipients. Reduce the number of recipients or change the number of tokens sent to each recipient.")
visible: !recipientsCountInstantiator.infinity &&
recipientsCountInstantiator.maximumRecipientsCount < airdropRecipientsSelector.count
}
WarningPanel {
Layout.fillWidth: true
Layout.topMargin: Style.current.padding
text: d.feesError
visible: !notEnoughTokensWarning.visible && d.showFees && d.feesError
}
StatusButton {
Layout.preferredHeight: 44
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
Layout.topMargin: Style.current.bigPadding
text: qsTr("Create airdrop")
enabled: root.isFullyFilled && !d.isFeeLoading && d.feesError === ""
onClicked: {
feesPopup.open()
}
}
SignMultiTokenTransactionsPopup {
id: feesPopup
destroyOnClose: false
model: feesModel
isFeeLoading: d.isFeeLoading
totalFeeText: d.totalFee
errorText: d.feesError
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}`
d.resetFees()
}
onSignTransactionClicked: {
const airdropTokens = ModelUtils.modelToArray(
root.selectedHoldingsModel,
["contractUniqueKey", "amount"])
const addresses_ = ModelUtils.modelToArray(
addresses, ["address"]).map(e => e.address)
const pubKeys = [...selectedKeysFilter.keys]
root.airdropClicked(airdropTokens, addresses_, pubKeys)
}
}
}
}