status-desktop/ui/imports/shared/views/HistoryView.qml

582 lines
22 KiB
QML

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