import QtQuick 2.15 import QtQuick.Layouts 1.15 import StatusQ.Core 0.1 import StatusQ.Core.Theme 0.1 import StatusQ.Controls 0.1 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.popups.community 1.0 import SortFilterProxyModel 0.2 StatusScrollView { id: root // 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 {} 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) { var tokenModel = null if(type === Constants.TokenType.ERC20) tokenModel = root.assetsModel else if (type === Constants.TokenType.ERC721) tokenModel = root.collectiblesModel const modelItem = CommunityPermissionsHelpers.getTokenByKey( tokenModel, key) 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 function prepareEntry(key, amount, type) { var tokenModel = null if(type === Constants.TokenType.ERC20) tokenModel = root.assetsModel else if (type === Constants.TokenType.ERC721) tokenModel = root.collectiblesModel const modelItem = CommunityPermissionsHelpers.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 } } } 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 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 { 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() } } } WarningPanel { 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 } StatusButton { Layout.preferredHeight: 44 Layout.alignment: Qt.AlignHCenter Layout.fillWidth: true Layout.topMargin: Style.current.bigPadding text: qsTr("Create airdrop") 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"]) const addresses_ = ModelUtils.modelToArray( addresses, ["address"]).map(e => e.address) const pubKeys = [...selectedKeysFilter.keys] 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") } } } } }