import QtQuick 2.15 import QtQml 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 import StatusQ.Core 0.1 import StatusQ.Core.Utils 0.1 as SQUtils import StatusQ.Components 0.1 import StatusQ.Controls 0.1 import StatusQ.Core.Theme 0.1 import StatusQ.Popups 0.1 import SortFilterProxyModel 0.2 import utils 1.0 import "../panels" import "../popups" import "../popups/send" import "../stores" import "../controls" import AppLayouts.Wallet.stores 1.0 as WalletStores import AppLayouts.Wallet.popups 1.0 import AppLayouts.Wallet.controls 1.0 import AppLayouts.Wallet.panels 1.0 ColumnLayout { id: root property var overview property var communitiesStore property bool showAllAccounts: false property bool displayValues: true property var sendModal property bool filterVisible property bool disableShadowOnScroll: false property bool hideVerticalScrollbar: false property int firstItemOffset: 0 property real yPosition: transactionListRoot.visibleArea.yPosition * transactionListRoot.contentHeight signal launchTransactionDetail(string txID) function resetView() { if (!!filterPanelLoader.item) { filterPanelLoader.item.resetView() } } onVisibleChanged: { d.openTxDetailsHash = "" } Component.onCompleted: { if (RootStore.transactionActivityStatus.isFilterDirty) { WalletStores.RootStore.currentActivityFiltersStore.applyAllFilters() } WalletStores.RootStore.currentActivityFiltersStore.updateCollectiblesModel() WalletStores.RootStore.currentActivityFiltersStore.updateRecipientsModel() } Connections { target: RootStore.transactionActivityStatus enabled: root.visible function onIsFilterDirtyChanged() { RootStore.updateTransactionFilterIfDirty() } function onFilterChainsChanged() { WalletStores.RootStore.currentActivityFiltersStore.updateCollectiblesModel() WalletStores.RootStore.currentActivityFiltersStore.updateRecipientsModel() } } Connections { target: WalletStores.RootStore.currentActivityFiltersStore enabled: root.visible function onDisplayTxDetails(txHash) { if (!d.openTxDetails(txHash)) { d.openTxDetailsHash = txHash } } } QtObject { id: d readonly property bool isInitialLoading: RootStore.loadingHistoryTransactions && transactionListRoot.count === 0 readonly property int loadingSectionWidth: 56 property bool firstSectionHeaderLoaded: false readonly property int maxSecondsBetweenRefresh: 3 property string openTxDetailsHash function openTxDetails(txID) { // Prevent opening details when loading, that will invalidate the model data if (RootStore.loadingHistoryTransactions) { return false } root.launchTransactionDetail(txID) return true } } InformationTag { id: betaTag Layout.fillWidth: true Layout.alignment: Qt.AlignTop Layout.topMargin: root.firstItemOffset Layout.preferredHeight: 56 visible: root.firstItemOffset === 0 // visible only in the main wallet view spacing: Style.current.halfPadding backgroundColor: Theme.palette.primaryColor3 bgRadius: Style.current.radius bgBorderColor: Theme.palette.primaryColor2 tagPrimaryLabel.textFormat: Text.RichText tagPrimaryLabel.font.pixelSize: Style.current.additionalTextSize tagPrimaryLabel.text: qsTr("Activity is in beta. If transactions are missing, check %1, %2, or %3.") .arg(Utils.getStyledLink("Etherscan", "https://etherscan.io/", tagPrimaryLabel.hoveredLink)) .arg(Utils.getStyledLink("OP Explorer", "https://optimistic.etherscan.io/", tagPrimaryLabel.hoveredLink)) .arg(Utils.getStyledLink("Arbiscan", "https://arbiscan.io/", tagPrimaryLabel.hoveredLink)) tagPrimaryLabel.onLinkActivated: (link) => { const explorerUrl = WalletStores.RootStore.showAllAccounts ? link : "%1/%2/%3".arg(link).arg(Constants.networkExplorerLinks.addressPath).arg(WalletStores.RootStore.selectedAddress) Global.openLinkWithConfirmation(explorerUrl, SQUtils.StringUtils.extractDomainFromLink(explorerUrl)) } asset { name: "warning" width: 20 height: 20 color: Theme.palette.primaryColor1 } HoverHandler { cursorShape: hovered && !!parent.tagPrimaryLabel.hoveredLink ? Qt.PointingHandCursor : undefined } } StyledText { id: nonArchivalNodeError Layout.fillWidth: true Layout.alignment: Qt.AlignTop Layout.topMargin: root.firstItemOffset visible: RootStore.isNonArchivalNode text: qsTr("Status Desktop is connected to a non-archival node. Transaction history may be incomplete.") font.pixelSize: Style.current.primaryTextFontSize color: Style.current.danger wrapMode: Text.WordWrap } ShapeRectangle { id: noTxs Layout.fillWidth: true Layout.preferredHeight: 42 Layout.topMargin: !nonArchivalNodeError.visible? root.firstItemOffset : 0 visible: !d.isInitialLoading && !WalletStores.RootStore.currentActivityFiltersStore.filtersSet && transactionListRoot.count === 0 font.pixelSize: Style.current.primaryTextFontSize text: qsTr("Activity for this account will appear here") } Loader { id: filterPanelLoader active: root.filterVisible && (d.isInitialLoading || transactionListRoot.count > 0 || WalletStores.RootStore.currentActivityFiltersStore.filtersSet) visible: active && !noTxs.visible asynchronous: true Layout.fillWidth: true sourceComponent: ActivityFilterPanel { activityFilterStore: WalletStores.RootStore.currentActivityFiltersStore store: WalletStores.RootStore hideNoResults: newTransactions.visible isLoading: d.isInitialLoading } } Item { id: transactionListWrapper Layout.alignment: Qt.AlignTop Layout.topMargin: nonArchivalNodeError.visible || noTxs.visible ? Style.current.padding : 0 Layout.fillWidth: true Layout.fillHeight: true Rectangle { // Shadow behind delegates when scrolling anchors.top: parent.top width: parent.width height: 4 color: Style.current.separator visible: !root.disableShadowOnScroll && !transactionListRoot.atYBeginning } StatusListView { id: transactionListRoot objectName: "walletAccountTransactionList" anchors.fill: parent onCountChanged: { if (!!d.openTxDetailsHash && root.visible) { if (d.openTxDetails(d.openTxDetailsHash)) { d.openTxDetailsHash = "" } else { RootStore.fetchMoreTransactions() } } } model: SortFilterProxyModel { id: txModel sourceModel: RootStore.historyTransactions // LocaleUtils is not accessable from inside expression, but local function works property var daysTo: (d1, d2) => LocaleUtils.daysTo(d1, d2) property var daysBetween: (d1, d2) => LocaleUtils.daysBetween(d1, d2) property var getFirstDayOfTheCurrentWeek: () => LocaleUtils.getFirstDayOfTheCurrentWeek() proxyRoles: ExpressionRole { name: "date" expression: { if (!model.activityEntry || model.activityEntry.timestamp === 0) return "" const currDate = new Date() const timestampDate = new Date(model.activityEntry.timestamp * 1000) const daysDiff = txModel.daysBetween(currDate, timestampDate) const daysToBeginingOfThisWeek = txModel.daysTo(timestampDate, txModel.getFirstDayOfTheCurrentWeek()) if (daysDiff < 1) { return qsTr("Today") } else if (daysDiff < 2) { return qsTr("Yesterday") } else if (daysToBeginingOfThisWeek >= 0) { return qsTr("Earlier this week") } else if (daysToBeginingOfThisWeek > -7) { return qsTr("Last week") } else if (currDate.getMonth() === timestampDate.getMonth() && currDate.getYear() === timestampDate.getYear()) { return qsTr("Earlier this month") } const previousMonthDate = (new Date(new Date().setDate(0))) // Special case for the end of the year if ((timestampDate.getMonth() === previousMonthDate.getMonth() && timestampDate.getYear() === previousMonthDate.getYear()) || (previousMonthDate.getMonth() === 11 && timestampDate.getMonth() === 0 && Math.abs(timestampDate.getYear() - previousMonthDate.getYear()) === 1)) { return qsTr("Last month") } return timestampDate.toLocaleDateString(Qt.locale(), "MMM yyyy") } } } delegate: transactionDelegateComponent headerPositioning: ListView.OverlayHeader footer: footerComp ScrollBar.vertical: StatusScrollBar { policy: root.hideVerticalScrollbar? ScrollBar.AlwaysOff : ScrollBar.AsNeeded } visibleArea.onYPositionChanged: { tryFetchMoreTransactions() } Connections { target: RootStore function onLoadingHistoryTransactionsChanged() { // Calling timer instead directly to not cause binding loop if (!RootStore.loadingHistoryTransactions) fetchMoreTimer.start() } } function tryFetchMoreTransactions() { if (d.isInitialLoading || !footerItem || !RootStore.historyTransactions.hasMore) return const footerYPosition = footerItem.height / contentHeight if (footerYPosition >= 1.0) { return } // On startup, first loaded ListView will have heightRatio equal 0 if (footerYPosition + visibleArea.yPosition + visibleArea.heightRatio > 1.0) { RootStore.fetchMoreTransactions() } } Timer { id: fetchMoreTimer interval: 1 onTriggered: transactionListRoot.tryFetchMoreTransactions() } } StatusButton { id: newTransactions anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top anchors.topMargin: Style.current.halfPadding text: qsTr("New transactions") visible: RootStore.newDataAvailable && !RootStore.loadingHistoryTransactions onClicked: RootStore.resetActivityData() icon.name: "arrow-up" radius: 36 type: StatusButton.Primary size: StatusBaseButton.Size.Tiny } } StatusMenu { id: delegateMenu hideDisabledItems: true property var transaction property var transactionDelegate function openMenu(delegate, mouse, data) { if (!delegate || !data) return delegateMenu.transactionDelegate = delegate delegateMenu.transaction = data popup(delegate, mouse.x, mouse.y) } onClosed: { delegateMenu.transaction = null delegateMenu.transactionDelegate = null } StatusAction { id: repeatTransactionAction text: qsTr("Repeat transaction") icon.name: "rotate" property alias tx: delegateMenu.transaction enabled: { if (!overview.isWatchOnlyAccount && !tx) return false return WalletStores.RootStore.isTxRepeatable(tx) } onTriggered: { if (!tx) return let asset = WalletStores.RootStore.getAssetForSendTx(tx) const req = Helpers.lookupAddressesForSendModal(WalletStores.RootStore.accounts, WalletStores.RootStore.savedAddresses, tx.sender, tx.recipient, asset, tx.isNFT, tx.amount) root.sendModal.preSelectedAccountAddress = req.preSelectedAccount.address root.sendModal.preSelectedRecipient = req.preSelectedRecipient root.sendModal.preSelectedRecipientType = req.preSelectedRecipientType root.sendModal.preSelectedHoldingID = req.preSelectedHoldingID ?? req.preSelectedHolding.uid root.sendModal.preSelectedHoldingType = req.preSelectedHoldingType root.sendModal.preSelectedSendType = req.preSelectedSendType root.sendModal.preDefinedAmountToSend = req.preDefinedAmountToSend root.sendModal.onlyAssets = false root.sendModal.open() } } StatusSuccessAction { text: qsTr("Copy details") successText: qsTr("Details copied") icon.name: "copy" onTriggered: { if (!delegateMenu.transactionDelegate) return WalletStores.RootStore.addressWasShown(delegateMenu.transaction.sender) if (delegateMenu.transaction.sender !== delegateMenu.transaction.recipient) { WalletStores.RootStore.addressWasShown(delegateMenu.transaction.recipient) } RootStore.fetchTxDetails(delegateMenu.transaction.id) let detailsObj = RootStore.getTxDetails() let detailsString = delegateMenu.transactionDelegate.getDetailsString(detailsObj) RootStore.copyToClipboard(detailsString) } } StatusMenuSeparator { visible: filterAction.enabled } StatusAction { id: filterAction text: qsTr("Filter by similar") icon.name: "filter" onTriggered: { const store = WalletStores.RootStore.currentActivityFiltersStore const tx = delegateMenu.transaction store.autoUpdateFilter = false store.resetAllFilters() const currentAddress = overview.mixedcaseAddress.toUpperCase() store.toggleType(tx.txType) // Contract deployment has always ETH symbol. Symbol doesn't affect this type if (tx.txType !== Constants.TransactionType.ContractDeployment) { const symbol = tx.symbol if (!!symbol) store.toggleToken(symbol) const inSymbol = tx.inSymbol if (!!inSymbol && inSymbol !== symbol) store.toggleToken(inSymbol) } if (showAllAccounts || tx.txType !== Constants.TransactionType.Bridge) { const recipient = tx.recipient.toUpperCase() if (!!recipient && recipient !== currentAddress && !/0X0+$/.test(recipient)) store.toggleRecents(recipient) } if (tx.isNFT) { const uid = store.collectiblesList.getUidForData(tx.tokenID, tx.tokenAddress, tx.chainId) if (!!uid) store.toggleCollectibles(uid) } store.autoUpdateFilter = true store.applyAllFilters() } } } Component { id: transactionDelegateComponent ColumnLayout { id: transactionDelegate required property var model required property int index readonly property bool displaySectionHeader: index === 0 || model.date !== txModel.get(index - 1).date readonly property bool displaySectionFooter: index === txModel.count-1 || model.date !== txModel.get(index + 1).date width: ListView.view.width spacing: 0 Item { Layout.fillWidth: true Layout.preferredHeight: root.firstItemOffset visible: transactionDelegate.index === 0 && root.firstItemOffset > 0 } Rectangle { Layout.fillWidth: true Layout.preferredHeight: childrenRect.height visible: transactionDelegate.displaySectionHeader color: Theme.palette.statusModal.backgroundColor ColumnLayout { anchors.left: parent.left anchors.top: parent.top width: parent.width spacing: Style.current.halfPadding Separator { Layout.fillWidth: true implicitHeight: 1 } StatusBaseText { leftPadding: Style.current.padding bottomPadding: Style.current.halfPadding text: transactionDelegate.model.date font.pixelSize: 13 } } } TransactionDelegate { Layout.fillWidth: true modelData: transactionDelegate.model.activityEntry timeStampText: isModelDataValid ? LocaleUtils.formatRelativeTimestamp(modelData.timestamp * 1000, true) : "" rootStore: RootStore walletRootStore: WalletStores.RootStore showAllAccounts: root.showAllAccounts displayValues: root.displayValues community: isModelDataValid && !!communityId && !!root.communitiesStore ? root.communitiesStore.getCommunityDetailsAsJson(communityId) : null onClicked: { if (mouse.button === Qt.RightButton) { delegateMenu.openMenu(this, mouse, modelData) } else { launchTransactionDetail(modelData.id) } } } Rectangle { Layout.fillWidth: true Layout.preferredHeight: 20 visible: transactionDelegate.displaySectionFooter color: Theme.palette.statusModal.backgroundColor } } } Component { id: footerComp ColumnLayout { id: footerColumn readonly property bool allActivityLoaded: !RootStore.historyTransactions.hasMore && transactionListRoot.count !== 0 width: root.width spacing: d.isInitialLoading ? 6 : 12 Separator { Layout.fillWidth: true Layout.topMargin: Style.current.halfPadding visible: d.isInitialLoading } StatusTextWithLoadingState { Layout.alignment: Qt.AlignLeft Layout.leftMargin: Style.current.padding text: "01.01.2000" width: d.loadingSectionWidth font.pixelSize: 15 loading: visible visible: d.isInitialLoading } Repeater { model: { if (!root.visible) return 0 if (!noTxs.visible) { const delegateHeight = 64 + footerColumn.spacing if (d.isInitialLoading) { return Math.floor(transactionListRoot.height / delegateHeight) } else if (RootStore.historyTransactions.hasMore) { return Math.max(3, Math.floor(transactionListRoot.height / delegateHeight) - 3) } } return 0 } TransactionDelegate { Layout.fillWidth: true rootStore: RootStore walletRootStore: WalletStores.RootStore loading: true } } Separator { Layout.topMargin: Style.current.bigPadding Layout.fillWidth: true visible: footerColumn.allActivityLoaded } StatusBaseText { Layout.fillWidth: true Layout.alignment: Qt.AlignHCenter text: qsTr("You have reached the beginning of the activity for this account") font.pixelSize: 13 color: Theme.palette.baseColor1 visible: footerColumn.allActivityLoaded horizontalAlignment: Text.AlignHCenter } StatusButton { Layout.alignment: Qt.AlignHCenter text: qsTr("Back to most recent transaction") visible: footerColumn.allActivityLoaded && transactionListRoot.contentHeight > transactionListRoot.height onClicked: transactionListRoot.positionViewAtBeginning() } Item { Layout.fillWidth: true Layout.preferredHeight: Style.current.halfPadding } } } }