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

649 lines
23 KiB
QML

import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import StatusQ 0.1
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
// A model containing accounts from which the fee can be paid:
required property var accountsModel
// Text to display as total fee
required property string totalFeeText
// Text to display in case of error
required property string feeErrorText
// Array containing the fees for each token
// [{contractUniqueKey: string, feeText: string}]
required property var feesPerSelectedContract
// Bool property indicating whether the fees are available
required property bool feesAvailable
property string enabledChainIds
property int viewWidth: 560 // by design
readonly property var selectedHoldingsModel: ListModel {}
// Array containing the contract keys and amounts of the tokens to be airdropped
readonly property alias selectedContractKeysAndAmounts: d.selectedContractKeysAndAmounts
// Array containing the addresses to which the tokens will be airdropped
readonly property alias selectedAddressesToAirdrop: d.selectedAddressesToAirdrop
// The address of the account from which the fee will be paid
readonly property alias selectedFeeAccount: d.selectedFeeAccount
// Bool property indicating whether the fees are shown
readonly property bool showingFees: d.showFees
onFeesPerSelectedContractChanged: {
feesModel.clear()
let feeSource = feesPerSelectedContract
if(!feeSource || feeSource.length === 0) // if no fees are available, show the placeholder text based on selection
feeSource = ModelUtils.modelToArray(root.selectedHoldingsModel, ["contractUniqueKey"])
feeSource.forEach(entry => {
feesModel.append({
contractUniqueKey: entry.contractUniqueKey,
title: qsTr("Airdrop %1 on %2")
.arg(ModelUtils.getByKey(root.selectedHoldingsModel, "contractUniqueKey", entry.contractUniqueKey, "symbol"))
.arg(ModelUtils.getByKey(root.selectedHoldingsModel, "contractUniqueKey", entry.contractUniqueKey, "networkText")),
feeText: entry.feeText ?? ""
})
})
}
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, string feeAccountAddress)
signal navigateToMintTokenSettings(bool isAssetType)
signal enableNetwork(int chainId)
function selectToken(key, amount, type) {
if(selectedHoldingsModel)
selectedHoldingsModel.clear()
const entry = d.prepareEntry(key, amount, type)
entry.valid = true
selectedHoldingsModel.append(entry)
}
function addAddresses(_addresses) {
addresses.addAddresses(_addresses)
}
QtObject {
id: d
readonly property int maxAirdropTokens: 5
readonly property int dropdownHorizontalOffset: 4
readonly property int dropdownVerticalOffset: 1
readonly property bool showFees: root.selectedHoldingsModel.count > 0
&& airdropRecipientsSelector.valid
&& airdropRecipientsSelector.count > 0
property string networkThatIsNotActive
property int networkIdThatIsNotActive
readonly property var selectedContractKeysAndAmounts: {
//Depedencies:
root.selectedHoldingsModel
holdingsModelTracker.revision
return ModelUtils.modelToArray(
root.selectedHoldingsModel,
["contractUniqueKey", "amount"])
}
readonly property var selectedAddressesToAirdrop: {
//Dependecies:
addresses
addressesModelTracker.revision
return ModelUtils.modelToArray(
addresses, ["address"]).map(e => e.address)
.concat([...selectedKeysFilter.keys])
}
readonly property string selectedFeeAccount: feesBox.accountsSelector.currentAccountAddress
function prepareEntry(key, amount, type) {
const tokenModel = type === Constants.TokenType.ERC20
? root.assetsModel : root.collectiblesModel
const modelItem = PermissionsHelpers.getTokenByKey(
tokenModel, key)
const multiplierIndex = modelItem.multiplierIndex
const amountNumber = AmountsArithmetic.toNumber(
amount, multiplierIndex)
const amountLocalized = LocaleUtils.numberToLocaleString(
amountNumber, -1)
return {
key, amount, type,
tokenText: amountLocalized + " " + modelItem.name,
tokenImage: modelItem.iconSource,
networkId: modelItem.chainId,
networkText: modelItem.chainName,
networkImage: Theme.svg(modelItem.chainIcon),
remainingSupply: modelItem.remainingSupply,
multiplierIndex: modelItem.multiplierIndex,
infiniteSupply: modelItem.infiniteSupply,
contractUniqueKey: modelItem.contractUniqueKey,
accountName: modelItem.accountName,
symbol: modelItem.symbol
}
}
}
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
const dividient = AmountsArithmetic.fromString(item.remainingSupply)
const divisor = AmountsArithmetic.fromString(item.amount)
const quotient = AmountsArithmetic.toNumber(
AmountsArithmetic.div(dividient, divisor))
min = Math.min(quotient, min)
}
infinity = min === Number.MAX_SAFE_INTEGER
maximumRecipientsCount = infinity ? 0 : min
}
delegate: QtObject {
readonly property string remainingSupply: model.remainingSupply
readonly property string amount: model.amount
readonly property bool infiniteSupply: model.infiniteSupply
readonly property bool valid: {
if (infiniteSupply)
return true
const recipientsCount = airdropRecipientsSelector.count
const demand = AmountsArithmetic.times(
AmountsArithmetic.fromString(amount),
recipientsCount)
const available = AmountsArithmetic.fromString(remainingSupply)
return AmountsArithmetic.cmp(demand, available) <= 0
}
onRemainingSupplyChanged: 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: Theme.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"])
}
function isChainEnabled(entry) {
// If the tokens' network is not activated, show a warning to the user
if (!!entry && !root.enabledChainIds.includes(entry.networkId)) {
d.networkThatIsNotActive = entry.networkText
d.networkIdThatIsNotActive = entry.networkId
} else {
d.networkThatIsNotActive = ""
d.networkIdThatIsNotActive = 0
}
}
onAddAsset: {
const entry = d.prepareEntry(key, amount, Constants.TokenType.ERC20)
entry.valid = true
selectedHoldingsModel.append(entry)
dropdown.close()
isChainEnabled(entry)
}
onAddCollectible: {
const entry = d.prepareEntry(key, amount, Constants.TokenType.ERC721)
entry.valid = true
selectedHoldingsModel.append(entry)
dropdown.close()
isChainEnabled(entry)
}
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 Constants.TokenType.ERC20:
dropdown.assetKey = modelItem.key
dropdown.assetAmount = modelItem.amount
dropdown.assetMultiplierIndex = modelItem.multiplierIndex
dropdown.setActiveTab(Constants.TokenType.ERC20)
break
case Constants.TokenType.ERC721:
dropdown.collectibleKey = modelItem.key
dropdown.collectibleAmount = modelItem.amount
dropdown.setActiveTab(Constants.TokenType.ERC721)
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.airdropAddress) && model.airdropAddress !== ""
}
}
onRemoveMemberRequested: {
const airdropAddress = ModelUtils.get(membersModel, index, "airdropAddress")
selectedKeysFilter.keys.delete(airdropAddress)
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
sorters : [
StringSorter {
roleName: "preferredDisplayName"
caseSensitivity: Qt.CaseInsensitive
}
]
filters: [
FastExpressionFilter {
enabled: membersDropdown.searchText !== ""
expression: {
const filter = membersDropdown.searchText.toLowerCase()
return model.alias.toLowerCase().includes(filter)
|| model.displayName.toLowerCase().includes(filter)
|| model.ensName.toLowerCase().includes(filter)
|| model.localNickname.toLowerCase().includes(filter)
|| model.pubKey.toLowerCase().includes(filter)
}
expectedRoles: ["alias", "displayName", "ensName", "localNickname", "pubKey"]
},
ValueFilter {
roleName: "airdropAddress"
value: ""
inverted: true
}
]
}
onBackButtonClicked: {
close()
airdropRecipientsSelector.openPopup(
recipientTypeSelectionDropdown)
}
onAddButtonClicked: {
selectedKeysFilter.keys = selectedKeys
close()
}
}
}
SequenceColumnLayout.Separator {}
FeesBox {
id: feesBox
Layout.fillWidth: true
model: feesModel
accountsSelector.model: root.accountsModel
totalFeeText: root.totalFeeText
placeholderText: qsTr("Add valid “What” and “To” values to see fees")
accountErrorText: root.feeErrorText
}
WarningPanel {
id: notEnoughTokensWarning
Layout.fillWidth: true
Layout.topMargin: Theme.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
}
NetworkWarningPanel {
visible: !!d.networkThatIsNotActive
Layout.fillWidth: true
Layout.topMargin: Theme.padding
networkThatIsNotActive: d.networkThatIsNotActive
onEnableNetwork: {
root.enableNetwork(d.networkIdThatIsNotActive)
d.networkThatIsNotActive = ""
d.networkIdThatIsNotActive = 0
}
}
StatusButton {
Layout.preferredHeight: 44
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
Layout.topMargin: Theme.bigPadding
text: qsTr("Create airdrop")
enabled: root.isFullyFilled && root.feesAvailable && root.feeErrorText === ""
onClicked: {
feesPopup.accountAddress = feesBox.accountsSelector.currentAccountAddress
feesPopup.accountName = feesBox.accountsSelector.currentAccount.name ?? ""
feesPopup.open()
}
}
SignTransactionsPopup {
id: feesPopup
property string accountAddress
destroyOnClose: false
model: feesModel
totalFeeText: root.totalFeeText
errorText: root.feeErrorText
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}`
}
onSignTransactionClicked: {
const airdropTokens = ModelUtils.modelToArray(
root.selectedHoldingsModel,
["contractUniqueKey", "amount"])
const addresses_ = ModelUtils.modelToArray(
addresses, ["address"]).map(e => e.address)
const airdropAddresses = [...selectedKeysFilter.keys]
root.airdropClicked(airdropTokens, addresses_.concat(airdropAddresses),
accountAddress)
}
}
}
}