import QtQuick 2.15 import QtQuick.Layouts 1.15 import QtQuick.Controls 2.15 import Qt.labs.settings 1.1 import QtQml 2.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 StatusQ.Models 0.1 import StatusQ.Popups 0.1 import StatusQ.Popups.Dialog 0.1 import shared.controls 1.0 import shared.panels 1.0 import shared.popups 1.0 import utils 1.0 import AppLayouts.Wallet.views.collectibles 1.0 import AppLayouts.Wallet.controls 1.0 import SortFilterProxyModel 0.2 ColumnLayout { id: root required property var ownedAccountsModel required property var controller required property string addressFilters required property string networkFilters property bool sendEnabled: true property bool filterVisible property bool isFetching: false // Indicates if a collectibles page is being loaded from the backend property bool isUpdating: false // Indicates if the collectibles list is being updated property bool isError: false // Indicates an error occurred while updating/fetching the collectibles list signal collectibleClicked(int chainId, string contractAddress, string tokenId, string uid, int tokenType) signal sendRequested(string symbol, int tokenType) signal receiveRequested(string symbol) signal switchToCommunityRequested(string communityId) signal manageTokensRequested() spacing: 0 QtObject { id: d readonly property int cellHeight: 225 readonly property int communityCellHeight: 242 readonly property int cellWidth: 176 readonly property int headerHeight: 56 readonly property bool isCustomView: cmbTokenOrder.currentValue === SortOrderComboBox.TokenOrderCustom readonly property var sourceModel: root.controller.sourceModel readonly property bool isLoading: root.isUpdating || root.isFetching function setSortByDateIsDisabled(value) { const orderByDateIndex = cmbTokenOrder.indexOfValue(SortOrderComboBox.TokenOrderDateAdded) cmbTokenOrder.model[orderByDateIndex].isDisabled = value cmbTokenOrder.modelChanged() if (!value && cmbTokenOrder.currentIndex === orderByDateIndex) { cmbTokenOrder.indexOfValue(SortOrderComboBox.TokenOrderAlpha) } } Component.onCompleted: { settings.sync() if (settings.currentSortValue === SortOrderComboBox.TokenOrderDateAdded && !d.hasAllTimestampsAggregator.value) { cmbTokenOrder.currentIndex = cmbTokenOrder.indexOfValue(SortOrderComboBox.TokenOrderAlpha) // Change to a different default option } else { cmbTokenOrder.currentIndex = cmbTokenOrder.indexOfValue(settings.currentSortValue) // Change to a different default option } } onIsLoadingChanged: { d.loadingItemsModel.refresh() } readonly property var loadingItemsModel: ListModel { Component.onCompleted: { refresh() } function refresh() { clear() if (d.isLoading) { for (let i = 0; i < 10; i++) { append({ isLoading: true }) } } } } readonly property var communityModel: CustomSFPM { isCommunity: true } readonly property var communityModelWithLoadingItems: ConcatModel { sources: [ SourceModel { model: d.communityModel markerRoleValue: "communityModel" }, SourceModel { model: d.loadingItemsModel markerRoleValue: "loadingItemsModel" } ] markerRoleName: "sourceGroup" } readonly property var nonCommunityModel: CustomSFPM { isCommunity: false } readonly property var nonCommunityModelWithLoadingItems: ConcatModel { sources: [ SourceModel { model: d.nonCommunityModel markerRoleValue: "nonCommunityModel" }, SourceModel { model: d.loadingItemsModel markerRoleValue: "loadingItemsModel" } ] markerRoleName: "sourceGroup" } readonly property var allCollectiblesModel: ConcatModel { sources: [ SourceModel { model: d.communityModel markerRoleValue: "loadingItemsModel" }, SourceModel { model: d.nonCommunityModel markerRoleValue: "nonCommunityModel" } ] markerRoleName: "sourceGroup" } readonly property bool hasRegularCollectibles: d.nonCommunityModel.count || d.loadingItemsModel.count readonly property bool hasCommunityCollectibles: d.communityModel.count || d.loadingItemsModel.count readonly property bool onlyRegularCollectiblesType: hasRegularCollectibles && !hasCommunityCollectibles readonly property var nwFilters: root.networkFilters.split(":") readonly property var addrFilters: root.addressFilters.split(":").map((addr) => addr.toLowerCase()) function getLatestTimestamp(ownership, filterList) { let latest = 0 if (!!ownership) { for (let i = 0; i < ownership.count; i++) { let accountAddress = ModelUtils.get(ownership, i, "accountAddress").toLowerCase() if (filterList.includes(accountAddress)) { let txTimestamp = ModelUtils.get(ownership, i, "txTimestamp") latest = Math.max(latest, txTimestamp) } } } return latest } function getBalance(ownership, filterList) { // Balance is a Uint256, so we need to use AmountsArithmetic to handle it let balance = AmountsArithmetic.fromNumber(0) if (!!ownership) { for (let i = 0; i < ownership.count; i++) { let accountAddress = ModelUtils.get(ownership, i, "accountAddress").toLowerCase() if (filterList.includes(accountAddress)) { let tokenBalanceStr = ModelUtils.get(ownership, i, "balance")+"" if (tokenBalanceStr !== "") { let tokenBalance = AmountsArithmetic.fromString(tokenBalanceStr) balance = AmountsArithmetic.sum(balance, tokenBalance) } } } // For simplicity, we limit the result to the maximum int manageable by QML const maxInt = 2147483647 if (AmountsArithmetic.cmp(balance, AmountsArithmetic.fromNumber(maxInt)) === 1) { return maxInt } } return AmountsArithmetic.toNumber(balance) } property FunctionAggregator hasAllTimestampsAggregator: FunctionAggregator { model: d.allCollectiblesModel initialValue: true roleName: "lastTxTimestamp" aggregateFunction: (aggr, value) => aggr && !!value onValueChanged: { d.setSortByDateIsDisabled(value) } } } component CustomSFPM: SortFilterProxyModel { id: customFilter property bool isCommunity sourceModel: d.sourceModel proxyRoles: [ JoinRole { name: "groupName" roleNames: ["collectionName", "communityName"] }, FastExpressionRole { name: "balance" expression: { d.addrFilters return d.getBalance(model.ownership, d.addrFilters) } expectedRoles: ["ownership"] }, FastExpressionRole { name: "lastTxTimestamp" expression: { d.addrFilters return d.getLatestTimestamp(model.ownership, d.addrFilters) } expectedRoles: ["ownership"] } ] filters: [ FastExpressionFilter { expression: { return d.nwFilters.includes(model.chainId+"") } expectedRoles: ["chainId"] }, ValueFilter { roleName: "balance" value: 0 inverted: true }, FastExpressionFilter { expression: { root.controller.revision return root.controller.filterAcceptsSymbol(model.symbol) && (customFilter.isCommunity ? !!model.communityId : !model.communityId) } expectedRoles: ["symbol", "communityId"] }, FastExpressionFilter { enabled: customFilter.isCommunity && cmbFilter.hasEnabledFilters expression: cmbFilter.selectedFilterGroupIds.includes(model.communityId) || (!model.communityId && cmbFilter.selectedFilterGroupIds.includes("")) expectedRoles: ["communityId"] }, FastExpressionFilter { enabled: !customFilter.isCommunity && cmbFilter.hasEnabledFilters expression: cmbFilter.selectedFilterGroupIds.includes(model.collectionUid) || (!model.collectionUid && cmbFilter.selectedFilterGroupIds.includes("")) expectedRoles: ["collectionUid"] } ] sorters: [ FastExpressionSorter { expression: { root.controller.revision return root.controller.compareTokens(modelLeft.symbol, modelRight.symbol) } enabled: d.isCustomView expectedRoles: ["symbol"] }, RoleSorter { roleName: cmbTokenOrder.currentSortRoleName sortOrder: cmbTokenOrder.currentSortOrder enabled: !d.isCustomView } ] } Settings { id: settings category: "CollectiblesViewSortSettings" property int currentSortValue: SortOrderComboBox.TokenOrderDateAdded property alias currentSortOrder: cmbTokenOrder.currentSortOrder property alias selectedFilterGroupIds: cmbFilter.selectedFilterGroupIds } Component.onDestruction: { settings.currentSortValue = cmbTokenOrder.currentValue } ColumnLayout { Layout.fillWidth: true Layout.fillHeight: false Layout.preferredHeight: root.filterVisible ? implicitHeight : 0 spacing: 20 opacity: root.filterVisible ? 1 : 0 Behavior on Layout.preferredHeight { NumberAnimation { duration: 200; easing.type: Easing.InOutQuad } } Behavior on opacity { NumberAnimation { duration: 200; easing.type: Easing.InOutQuad } } StatusDialogDivider { Layout.fillWidth: true } RowLayout { Layout.fillWidth: true spacing: Style.current.halfPadding FilterComboBox { id: cmbFilter regularTokensModel: root.controller.regularTokensModel collectionGroupsModel: root.controller.collectionGroupsModel communityTokenGroupsModel: root.controller.communityTokenGroupsModel hasCommunityGroups: d.hasCommunityCollectibles } Rectangle { Layout.preferredHeight: 34 Layout.preferredWidth: 1 Layout.leftMargin: 12 Layout.rightMargin: 12 color: Theme.palette.baseColor2 } StatusBaseText { color: Theme.palette.baseColor1 font.pixelSize: Style.current.additionalTextSize text: qsTr("Sort by:") } SortOrderComboBox { id: cmbTokenOrder hasCustomOrderDefined: root.controller.hasSettings model: [ { value: SortOrderComboBox.TokenOrderDateAdded, text: qsTr("Date added"), icon: "", sortRoleName: "lastTxTimestamp", isDisabled: !d.hasAllTimestampsAggregator.value }, // Custom SFPM role { value: SortOrderComboBox.TokenOrderAlpha, text: qsTr("Collectible name"), icon: "", sortRoleName: "name" }, { value: SortOrderComboBox.TokenOrderGroupName, text: qsTr("Collection/community name"), icon: "", sortRoleName: "groupName" }, // Custom SFPM role communityName || collectionName { value: SortOrderComboBox.TokenOrderCustom, text: qsTr("Custom order"), icon: "", sortRoleName: "" }, { value: SortOrderComboBox.TokenOrderNone, text: "---", icon: "", sortRoleName: "" }, // separator { value: SortOrderComboBox.TokenOrderCreateCustom, text: hasCustomOrderDefined ? qsTr("Edit custom order →") : qsTr("Create custom order →"), icon: "", sortRoleName: "" } ] onCreateOrEditRequested: { root.manageTokensRequested() } } Item { Layout.fillWidth: true } StatusLinkText { visible: cmbFilter.hasEnabledFilters normalColor: Theme.palette.primaryColor1 text: qsTr("Clear filter") onClicked: cmbFilter.clearFilter() } } StatusDialogDivider { Layout.fillWidth: true } } ShapeRectangle { Layout.fillWidth: true Layout.topMargin: Style.current.padding visible: !d.hasRegularCollectibles && !d.hasCommunityCollectibles text: qsTr("Collectibles will appear here") } DoubleFlickableWithFolding { id: doubleFlickable Layout.fillWidth: true Layout.fillHeight: true clip: true flickable1: CustomGridView { id: communityCollectiblesView header: d.hasCommunityCollectibles ? communityHeaderComponent : null width: doubleFlickable.width cellHeight: d.communityCellHeight model: d.communityModelWithLoadingItems Component { id: communityHeaderComponent FoldableHeader { height: d.headerHeight width: doubleFlickable.width title: qsTr("Community minted") titleColor: Theme.palette.baseColor1 folded: doubleFlickable.flickable1Folded rightAdditionalComponent: StatusFlatButton { icon.name: "info" textColor: Theme.palette.baseColor1 onClicked: Global.openPopup(communityInfoPopupCmp) } onToggleFolding:doubleFlickable.flip1Folding() } } } flickable2: CustomGridView { id: regularCollectiblesView header: !d.hasRegularCollectibles || d.onlyRegularCollectiblesType ? null : regularHeaderComponent width: doubleFlickable.width cellHeight: d.cellHeight model: d.nonCommunityModelWithLoadingItems Component { id: regularHeaderComponent FoldableHeader { height: d.headerHeight width: doubleFlickable.width title: qsTr("Others") titleColor: Theme.palette.baseColor1 folded: doubleFlickable.flickable2Folded onToggleFolding:doubleFlickable.flip2Folding() } } } } component CustomGridView: StatusGridView { id: gridView interactive: false cellWidth: d.cellWidth delegate: collectibleDelegate } Component { id: collectibleDelegate CollectibleView { width: d.cellWidth height: isCommunityCollectible ? d.communityCellHeight : d.cellHeight title: model.name ? model.name : "..." subTitle: model.collectionName ? model.collectionName : model.collectionUid ? model.collectionUid : "" mediaUrl: model.mediaUrl ?? "" mediaType: model.mediaType ?? "" fallbackImageUrl: model.imageUrl ?? "" backgroundColor: model.backgroundColor ? model.backgroundColor : "transparent" isLoading: !!model.isLoading privilegesLevel: model.communityPrivilegesLevel ?? Constants.TokenPrivilegesLevel.Community ornamentColor: model.communityColor ?? "transparent" communityId: model.communityId ?? "" communityName: model.communityName ?? "" communityImage: model.communityImage ?? "" balance: model.balance ?? 1 onClicked: root.collectibleClicked(model.chainId, model.contractAddress, model.tokenId, model.symbol, model.tokenType) onRightClicked: { let ownedByUser = false const ownedByAccount = ModelUtils.get(model.ownership, 0) if (!!ownedByAccount && !!ownedByAccount.accountAddress) { ownedByUser = ModelUtils.contains(root.ownedAccountsModel, "address", ownedByAccount.accountAddress, Qt.CaseInsensitive) } Global.openMenu(tokenContextMenu, this, {symbol: model.symbol, tokenName: model.name, tokenImage: model.imageUrl, communityId: model.communityId, communityName: model.communityName, communityImage: model.communityImage, tokenType: model.tokenType, soulbound: model.soulbound, ownedByUser: ownedByUser}) } onSwitchToCommunityRequested: (communityId) => root.switchToCommunityRequested(communityId) } } Component { id: tokenContextMenu StatusMenu { id: tokenMenu onClosed: destroy() property string symbol property string tokenName property string tokenImage property string communityId property string communityName property string communityImage property int tokenType property bool ownedByUser property bool soulbound // Show send button for owned collectibles // Disable send button for owned soulbound collectibles Instantiator { model: tokenMenu.ownedByUser ? 1 : 0 delegate: StatusAction { enabled: root.sendEnabled && !tokenMenu.soulbound visibleOnDisabled: true icon.name: "send" text: qsTr("Send") onTriggered: root.sendRequested(symbol, tokenType) } onObjectAdded: tokenMenu.insertAction(0, object) onObjectRemoved: tokenMenu.removeAction(0) } StatusAction { icon.name: "receive" text: qsTr("Receive") onTriggered: root.receiveRequested(symbol) } StatusMenuSeparator {} StatusAction { icon.name: "settings" text: qsTr("Manage tokens") onTriggered: root.manageTokensRequested() } StatusAction { enabled: symbol !== Constants.ethToken type: StatusAction.Type.Danger icon.name: "hide" text: qsTr("Hide collectible") onTriggered: Global.openConfirmHideCollectiblePopup(symbol, tokenName, tokenImage, !!communityId) } StatusAction { enabled: !!communityId type: StatusAction.Type.Danger icon.name: "hide" text: qsTr("Hide all collectibles from this community") onTriggered: Global.openPopup(confirmHideCommunityCollectiblesPopup, {communityId, communityName, communityImage}) } } } Component { id: communityInfoPopupCmp StatusDialog { destroyOnClose: true title: qsTr("What are community collectibles?") standardButtons: Dialog.Ok width: 520 contentItem: StatusBaseText { wrapMode: Text.Wrap text: qsTr("Community collectibles are collectibles that have been minted by a community. As these collectibles cannot be verified, always double check their origin and validity before interacting with them. If in doubt, ask a trusted member or admin of the relevant community.") } } } Component { id: confirmHideCommunityCollectiblesPopup ConfirmationDialog { property string communityId property string communityName property string communityImage width: 520 destroyOnClose: true confirmButtonLabel: qsTr("Hide '%1' collectibles").arg(communityName) cancelBtnType: "" showCancelButton: true headerSettings.title: qsTr("Hide %1 community collectibles").arg(communityName) headerSettings.asset.name: communityImage confirmationText: qsTr("Are you sure you want to hide all community collectibles minted by %1? You will no longer see or be able to interact with these collectibles anywhere inside Status.").arg(communityName) onCancelButtonClicked: close() onConfirmButtonClicked: { root.controller.showHideGroup(communityId, false) close() Global.displayToastMessage( qsTr("%1 community collectibles were successfully hidden. You can toggle collectible visibility via %2.").arg(communityName) .arg(`` + qsTr("Settings", "Go to Settings") + ""), "", "checkmark-circle", false, Constants.ephemeralNotificationType.success, "" ) } } } }