Files
status-app/ui/app/AppLayouts/Wallet/views/LeftTabView.qml
T
Alex Jbanca 754276d853 perf(wallet): build the wallet section around what the user is waiting for
A wallet section activation built everything it would ever need before it
showed anything: both panels inline, both detail views, every row of two
lists synchronously, and a set of subtrees that exist for cases that almost
never occur. This reorders that around a single rule - build what is on
screen first, build the rest in the incubation controller's metered bites,
and build the rest of the rest only when something asks for it.

Lists fill preemptibly. A ListView refill is one uninterruptible call inside
the window's polish phase, and QQuickItemView creates visible delegates with
AsynchronousIfNested - so a list first laid out after its enclosing Loader is
already Ready builds every visible row synchronously. On a warm load that was
one 34.7ms GUI-thread block against a 3.2ms budget for the assets list, and
8.7-13.5ms of post-content polish across both lists. The assets and accounts
delegates become TokenDelegateShell and WalletAccountDelegateShell: an Item
carrying the row's geometry and its placeholder tiles, with the real row
behind a nested asynchronous Loader. The refill builds shells; the rows
incubate afterwards.

Row height is the shell's, not the content's. StatusListItem.implicitHeight
is Math.max(64, titleArea.height + 16), and both a token row and an account
row resolve to that 64px floor, which is what placeholderHeight is.
tst_TokenDelegateShell and tst_WalletAccountDelegateShell gate the two
staying equal, so a change that makes a row taller fails a test rather than
moving contentHeight and the scroll position mid-fill. Each placeholder
mirrors the skeleton the per-tab loader showed a moment earlier, so the two
loading states read as one handoff.

Subtrees built for the exceptional case are latched off. The token row's two
warning buttons, the asset detail's four chain-tag warning buttons, its
header community tag and its "Minted by" tile were all built unconditionally
and merely hidden. Each now sits behind the condition that used to drive
`visible`. AssetsDetailsHeader loses its `communityTag` alias, which existed
only so callers could reach in and set the tag's contents; it takes
communityName/communityImage and builds the tag itself.

The two detail views move behind async Loaders keyed on the stack index. The
token to show becomes state held in `d` and bound into the view rather than
assigned into it, so a second click landing mid-incubation wins without a
queue-and-replay; the resets that hung off onVisibleChanged hang off
onActiveChanged, which fires on the same edges.

Panels build in the order the user sees them. WalletLayout declared both
panels as plain object bindings, so activation built LeftTabView and the
centre StackView inline and the chrome's two PanelSwapGates could not fire
independently - they opened 5ms apart because both panel properties resolve
when WalletLayout completes. Each panel now sits behind its own async Loader
with its own readiness, the primary one first, and once a skeleton is on
screen with no panel switch running the primary panel is built synchronously:
the pacing exists to protect section-switch animations, and here the only
thing a synchronous block could stutter is the skeleton it replaces.

Wrapping the panels in Loaders exposed three geometry defects. A Loader
reports its item's implicit size as its own, so the centre StackView's
content width propagated into the chrome and pushed the panel past the right
edge on a narrow screen; the loaders are bound to the chrome's geometry for
the unparented phase, the stack no longer anchors to its loader, and the side
inset moves onto the content where it belongs.

Panel geometry is coalesced across a rotation. A device rotation walks the
window through nine sizes over ~430ms and LayoutItemProxy forwarded every one
to the section's panels - a full relayout of a populated subtree each time -
and parked a panel at a degenerate box for ~148ms on the portrait/landscape
handoff. SectionPanelSlot replaces the panel proxies in both sub-layouts and
publishes the box each slot will give its panel, which is also what a section
building panels outside the tree needs: the centre slot cannot be guessed, as
it is short by the header and footer and narrow by the left column in
landscape. Wallet and chat both pre-size their incubating panels to it.

Two fixes fall out of that work. BaseProxyPanel looked its SwipeView page up
at a fixed implicitIndex, but SwipeView indices close up as pages come and
go, so hiding the right panel with no left panel present silently left its
page in the view, still swipeable; it now looks the page up by identity.
SectionPanelSlot also has to be listed in statusq.qrc - without it the type
is absent from the binary's resources, StatusSectionLayout becomes
unavailable and the UI process dies precompiling AppMain.

Measured on a whale profile, Release, arms alternated between rounds:

  warm t_first_asset_row       119-159ms -> 51-74ms
  warm max_stall_ms             31.7-42.6 -> 17.2-26.3ms
  section ready                     631ms -> 207ms
  visible panel promoted            609ms -> 338ms
  cold: skeleton shown -> visible  2272ms -> 1836ms
  objects_total (cold)               4602 -> 3344   (-27%)
  objects_settled                    9569 -> 8297   (-13%)
  token row QObjects                  326 -> 279
  panel geometry distinct sizes    24 -> 5 centre, 19 -> 2 left
  degenerate 0x0 box written    12 occurrences -> 0

Device numbers are only comparable once the app has settled; a run taken
while startup backend work is still in flight measured 925ms for a path that
measures ~520ms settled.

Measurements come from an offscreen storybook wallet bench that is not part
of this PR; see branch `feat/storybook-wallet-loader`.
2026-08-23 09:40:36 +03:00

536 lines
22 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Qt5Compat.GraphicalEffects
import SortFilterProxyModel
import StatusQ.Core
import StatusQ.Core.Utils as SQUtils
import StatusQ.Core.Theme
import StatusQ.Components
import StatusQ.Controls
import StatusQ.Popups
import utils
import shared
import shared.panels
import shared.controls
import shared.popups
import "../controls"
import "../panels"
import "../popups"
import "../stores"
Rectangle {
id: root
objectName: "walletLeftTab"
required property LeftTabViewState viewState
// Child -> parent: user actions that require store/backend handling.
signal addAccountPopupRequested()
signal addWatchOnlyAccountPopupRequested()
signal editAccountPopupRequested(string address)
signal watchAccountHiddenFromTotalBalanceUpdated(string address, bool hideFromTotalBalance)
signal accountDeletionRequested(string address, string password)
signal userAuthenticationRequested(string requestedBy)
// Child -> parent: navigation.
signal allAccountsSelected()
signal accountSelected(string address)
signal savedAddressesSelected()
signal followingAddressesSelected()
color: Theme.palette.secondaryMenuBackground
Component.onCompleted: {
d.loaded = true
}
QtObject {
id: d
property bool loaded: false
readonly property string removeAccountIdentifier: "wallet-section-remove-account"
readonly property real bottomSafeMargin: root.SafeArea.margins.bottom
readonly property real footerHeight: footer.height + followingAddressesFooter.height
// null until the async loader finishes; consumers must fall back gracefully
readonly property var accountsListView: walletAccountsListViewLoader.item
}
Loader {
id: walletAccountContextMenu
active: false
sourceComponent: AccountContextMenu {
property var account: null
address: {
if (!account)
return ""
return account.mixedcaseAddress
}
name: account ? account.name : ""
walletType: account ? account.walletType : ""
canDelete: account && !account.isWallet
hideFromTotalBalance: account && account.hideFromTotalBalance
onClosed: {
walletAccountContextMenu.active = false
}
onAddNewAccountClicked: {
root.addAccountPopupRequested()
}
onAddWatchOnlyAccountClicked: {
root.addWatchOnlyAccountPopupRequested()
}
onEditAccountClicked: {
if (!account)
return
root.editAccountPopupRequested(account.address)
}
onDeleteAccountClicked: {
if (!account)
return
removeAccountConfirmation.accountType = account.walletType
removeAccountConfirmation.accountName = account.name
removeAccountConfirmation.accountAddress = account.address
removeAccountConfirmation.accountDerivationPath = account.path
removeAccountConfirmation.emoji = account.emoji
removeAccountConfirmation.colorId = account.colorId
removeAccountConfirmation.migratedToColdWallet = account.migratedToColdWallet
removeAccountConfirmation.active = true
}
onHideFromTotalBalanceClicked: function (hideFromTotalBalance) {
if (!account)
return
root.watchAccountHiddenFromTotalBalanceUpdated(account.address, hideFromTotalBalance)
}
}
}
Loader {
id: removeAccountConfirmation
active: false
property string accountType
property string accountKeyUid
property string accountName
property string accountAddress
property string accountDerivationPath
property string emoji
property string colorId
property bool migratedToColdWallet
sourceComponent: RemoveAccountConfirmationPopup {
accountType: removeAccountConfirmation.accountType
accountName: removeAccountConfirmation.accountName
accountAddress: removeAccountConfirmation.accountAddress
accountDerivationPath: removeAccountConfirmation.accountDerivationPath
emoji: removeAccountConfirmation.emoji
color: Utils.getColorForId(Theme.palette, removeAccountConfirmation.colorId)
function doDeletion(password) {
close()
root.accountDeletionRequested(removeAccountConfirmation.accountAddress, password)
}
onClosed: {
removeAccountConfirmation.active = false
}
onRemoveAccount: {
if (removeAccountConfirmation.accountType === Constants.watchWalletType
|| removeAccountConfirmation.migratedToColdWallet) {
doDeletion("")
return
}
root.userAuthenticationRequested(d.removeAccountIdentifier)
}
Connections {
target: RootStore
enabled: removeAccountConfirmation.active
function onLoggedInUserAuthenticated(requestedBy: string, password: string, pin: string, keyUid: string, keycardUid: string) {
if (d.removeAccountIdentifier !== requestedBy || password === "") {
return
}
doDeletion(password)
}
}
}
onLoaded: {
removeAccountConfirmation.item.open()
}
}
StatusMouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: {
if (mouse.button === Qt.RightButton) {
walletAccountContextMenu.active = true
walletAccountContextMenu.item.popup(mouse.x, mouse.y)
}
}
}
ColumnLayout {
anchors.fill: parent
spacing: 0
Item {
Layout.fillWidth: true
Layout.preferredHeight: icon.height
Layout.leftMargin: Theme.padding
Layout.rightMargin: Theme.padding
Layout.topMargin: Theme.padding
StatusMouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: mouse.accepted = true
}
StatusBaseText {
id: walletTitleText
text: qsTr("Wallet")
font.weight: Font.Bold
font.pixelSize: Theme.secondaryAdditionalTextSize
color: Theme.palette.directColor1
anchors.verticalCenter: parent.verticalCenter
}
StatusRoundButton {
id: icon
objectName: "addAccountButton"
Accessible.name: SQUtils.Utils.formatAccessibleName(
qsTr("Add account"),
"addAccountButton"
)
icon.name: "add-circle"
anchors.right: parent.right
anchors.rightMargin: -Theme.smallPadding
anchors.verticalCenter: parent.verticalCenter
icon.width: 24
icon.height: 24
color: hovered || highlighted ? Theme.palette.primaryColor3
: "transparent"
onClicked: root.addAccountPopupRequested()
}
}
Rectangle {
Layout.fillWidth: true
Layout.minimumHeight: Theme.bigPadding
color: root.color
z: 2
layer.enabled: !(d.accountsListView?.atYBeginning ?? true)
layer.effect: DropShadow {
verticalOffset: 10
radius: 20
samples: 41
fast: true
cached: true
color: Theme.palette.dropShadow2
}
}
Item {
Layout.fillHeight: true
Layout.fillWidth: true
// Skeleton mimicking the accounts list, shown while the ListView
// incubates and released afterwards: an alive invisible skeleton
// re-evaluates its tile geometry on every resize
Loader {
anchors {
top: parent.top
left: parent.left
right: parent.right
margins: Theme.padding
}
active: walletAccountsListViewLoader.status !== Loader.Ready
visible: active
sourceComponent: WalletAccountsSkeleton {}
}
Loader {
id: walletAccountsListViewLoader
objectName: "walletAccountsListLoader"
asynchronous: true
visible: status === Loader.Ready
anchors {
top: parent.top
left: parent.left
right: parent.right
}
height: Math.max(0, parent.height - d.footerHeight)
sourceComponent: StatusListView {
id: walletAccountsListView
objectName: "walletAccountsListView"
spacing: Theme.smallPadding
currentIndex: -1
highlightRangeMode: ListView.ApplyRange
preferredHighlightBegin: 0
preferredHighlightEnd: height
bottomMargin: Theme.padding
verticalScrollBar.implicitWidth: Math.max(Theme.halfPadding, 8)
readonly property Item firstItem: count > 0 ? itemAtIndex(0) : null
readonly property bool footerOverlayed: d.loaded && contentHeight > availableHeight
delegate: WalletAccountDelegateShell {
id: rowShell
objectName: "walletAccountRowShell"
width: ListView.view.width - Theme.padding * 2
anchors.horizontalCenter: !!parent ? parent.horizontalCenter : undefined
// Selection lives on the shell so the list still tracks the
// current row while the content is incubating.
readonly property bool selected:
viewState.selectedAddress.toLowerCase() === model.address.toLowerCase()
onSelectedChanged: {
if (selected)
ListView.view.currentIndex = index
}
Component.onCompleted: {
if (selected)
ListView.view.currentIndex = index
}
sourceComponent: StatusListItem {
objectName: "walletAccountListItem"
readonly property bool itemLoaded: !model.assetsLoading // needed for e2e tests
width: rowShell.width
highlighted: rowShell.selected
title: model.name
subTitle: !model.hideFromTotalBalance ? LocaleUtils.currencyAmountToLocaleString(model.currencyBalance): ""
asset.emoji: !!model.emoji ? model.emoji: ""
asset.color: Utils.getColorForId(Theme.palette, model.colorId)
asset.name: !model.emoji ? "filled-account": ""
asset.width: 40
asset.height: 40
asset.letterSize: 14
asset.isLetterIdenticon: !!model.emoji ? true : false
asset.bgColor: Theme.palette.primaryColor3
statusListItemTitle.font.weight: Font.Medium
color: sensor.containsMouse || highlighted ? Theme.palette.baseColor3 : "transparent"
statusListItemSubTitle.loading: !!model.assetsLoading
errorMode: viewState.accountBalanceNotAvailable
errorIcon.tooltip.maxWidth: 300
errorIcon.tooltip.text: viewState.accountBalanceNotAvailableText
onClicked: function(itemId, mouse) {
if (mouse.button === Qt.RightButton) {
walletAccountContextMenu.active = true
walletAccountContextMenu.item.account = model
walletAccountContextMenu.item.popup(this, mouse.x, mouse.y)
return
}
root.accountSelected(model.address)
}
components: [
StatusIcon {
width: !!icon ? 15: 0
height: !!icon ? 15: 0
color: Theme.palette.directColor1
icon: model.walletType === Constants.watchWalletType ? "show" : ""
},
StatusIcon {
width: !!icon ? 15: 0
height: !!icon ? 15: 0
color: Theme.palette.directColor1
icon: model.migratedToColdWallet ? "keycard" : ""
}
]
}
}
header: StatusFlatButton {
id: header
verticalPadding: Theme.padding
horizontalPadding: Theme.padding
highlighted: viewState.showAllAccounts
objectName: "allAccountsBtn"
leftInset: Theme.padding
bottomInset: Theme.padding
leftPadding: Theme.xlPadding
bottomPadding: Theme.bigPadding
background: Rectangle {
radius: Theme.radius
color: header.highlighted || header.hovered ? Theme.palette.backgroundHover : root.color
implicitWidth: parent.ListView.view.width - Theme.padding * 2
}
onClicked: root.allAccountsSelected()
contentItem: ColumnLayout {
spacing: 0
StatusBaseText {
id: allAccounts
color: Theme.palette.baseColor1
text: qsTr("All accounts")
font.weight: Font.Medium
font.pixelSize: Theme.primaryTextFontSize
lineHeightMode: Text.FixedHeight
lineHeight: 22
}
RowLayout {
spacing: 4
StatusTextWithLoadingState {
id: walletAmountValue
objectName: "walletLeftListAmountValue"
customColor: Theme.palette.textColor
text: viewState.totalCurrencyBalance
? LocaleUtils.currencyAmountToLocaleString(viewState.totalCurrencyBalance, {noSymbol: true})
: ""
font.pixelSize: Theme.fontSize(22)
loading: viewState.balanceLoading
lineHeightMode: Text.FixedHeight
lineHeight: 36
verticalAlignment: Text.AlignVCenter
}
StatusTextWithLoadingState {
customColor: Theme.palette.textColor
text: viewState.totalCurrencyBalance ? viewState.totalCurrencyBalance.symbol : ""
font.pixelSize: Theme.additionalTextSize
loading: viewState.balanceLoading
font.weight: Font.Medium
lineHeightMode: Text.FixedHeight
lineHeight: 22
verticalAlignment: Text.AlignBottom
}
visible: !viewState.accountBalanceNotAvailable
}
StatusFlatRoundButton {
id: errorIcon
Layout.preferredWidth: 14
Layout.preferredHeight: 14
icon.width: 14
icon.height: 14
icon.name: "tiny/warning"
icon.color: Theme.palette.dangerColor1
tooltip.text: viewState.accountBalanceNotAvailableText
tooltip.maxWidth: 200
visible: viewState.accountBalanceNotAvailable
}
}
}
model: SortFilterProxyModel {
sourceModel: viewState.accountsModel
sorters: RoleSorter { roleName: "position"; sortOrder: Qt.AscendingOrder }
}
}
}
Control {
id: footer
anchors {
top: parent.top
// Bottom Margin is not applied to ListView if it's fully visible;
// while the list is still loading, keep the footer at the bottom
topMargin: {
const listView = d.accountsListView
if (!listView)
return parent.height - d.footerHeight
return Math.min(listView.contentHeight, parent.height - d.footerHeight) + (listView.footerOverlayed ? 0 : listView.bottomMargin)
}
left: parent.left
right: parent.right
}
horizontalPadding: Theme.padding
verticalPadding: Theme.padding
background: Rectangle {
id: footerBackground
color: root.color
implicitWidth: root.width
implicitHeight: (d.accountsListView?.firstItem?.height ?? Theme.xlPadding*2) + Theme.xlPadding
layer.enabled: (d.accountsListView?.footerOverlayed ?? false) && !(d.accountsListView?.atYEnd ?? true)
layer.effect: DropShadow {
verticalOffset: -10
radius: 20
samples: 41
fast: true
cached: true
color: Theme.palette.dropShadow2
}
Separator {
anchors.top: parent.top
anchors.topMargin: -1
width: parent.width
}
}
contentItem: StatusFlatButton {
objectName: "savedAddressesBtn"
highlighted: viewState.showSavedAddresses
hoverColor: Theme.palette.backgroundHover
asset.bgColor: Theme.palette.primaryColor3
text: qsTr("Saved addresses")
icon.name: "address"
icon.width: 40
icon.height: 40
icon.color: Theme.palette.primaryColor1
isRoundIcon: true
textColor: Theme.palette.directColor1
textFillWidth: true
spacing: d.accountsListView?.firstItem?.contentItem?.statusListItemTitleArea?.anchors.leftMargin ?? Theme.padding
onClicked: root.savedAddressesSelected()
}
}
Control {
id: followingAddressesFooter
anchors {
top: footer.bottom
left: parent.left
right: parent.right
}
horizontalPadding: Theme.padding
topPadding: 0
bottomPadding: d.bottomSafeMargin
contentItem: StatusFlatButton {
objectName: "followingAddressesBtn"
highlighted: viewState.showFollowingAddresses
hoverColor: Theme.palette.backgroundHover
asset.bgColor: Theme.palette.primaryColor3
text: qsTr("Onchain friends")
icon.name: "contact"
icon.width: 40
icon.height: 40
icon.color: Theme.palette.primaryColor1
isRoundIcon: true
textColor: Theme.palette.directColor1
textFillWidth: true
spacing: d.accountsListView?.firstItem?.contentItem?.statusListItemTitleArea?.anchors.leftMargin ?? Theme.padding
onClicked: root.followingAddressesSelected()
}
}
}
}
}