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) } } } }