mirror of
https://github.com/status-im/status-desktop.git
synced 2025-01-11 06:47:01 +00:00
8db0ac94f0
Fixes #15507 Makes sure to enable the network where the owner token was minted before minting or airdroping a token. This makes sure that the account selector shows the right balance and there is no ETH error before doing the transation
644 lines
22 KiB
QML
644 lines
22 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
|
|
|
|
// 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: Style.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: 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"])
|
|
}
|
|
|
|
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
|
|
|
|
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)
|
|
}
|
|
},
|
|
ExpressionFilter {
|
|
expression: !!model.airdropAddress
|
|
}
|
|
]
|
|
}
|
|
|
|
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: 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
|
|
}
|
|
|
|
NetworkWarningPanel {
|
|
visible: !!d.networkThatIsNotActive
|
|
Layout.fillWidth: true
|
|
Layout.topMargin: Style.current.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: Style.current.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)
|
|
}
|
|
}
|
|
}
|
|
}
|