Files
status-app/ui/imports/shared/views/AssetsView.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

425 lines
19 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQml.Models
import QtModelsToolkit
import StatusQ
import StatusQ.Core
import StatusQ.Core.Theme
import StatusQ.Popups.Dialog
import AppLayouts.Wallet.controls
import shared.controls
import shared.popups
import utils
Control {
id: root
/**
Model contract (terminal model — already visible-filtered and sorted; no
proxy is placed above it here):
- provides the derived roles `isCommunity`, `marketBalance`,
`change1DayFiat` and a `chainIds` role (comma-separated chain ids)
in addition to the base token roles below;
- re-sorts in place via `sortBy(roleName, order)` — this view emits the
`sortRequested` intent, the consumer wires it to the model.
Expected model structure:
key [string] - refers to token group key
name [string] - token's name
symbol [string] - token's symbol
decimals [int] - token's decimals
logoUri [string] - token's image
tokens [model] - contains tokens that belong to the same token group (a single token per chain), has at least a single token
key [string] - token key
groupKey [string] - token group key
crossChainId [string] - cross chain id
address [string] - token's address
name: [string] - token's name
symbol: [string] - token's symbol
decimals: [int] - token's decimals
chainId: [int] - token's chain id
image: [string] - token's image
customToken [bool] - `true` if the it's a custom token
communityId [string] - contains community id if the token is a community token
balances [model] - contains a single entry for (token, accountAddress) pair
account [string] - wallet account address
groupKey [string] - group key that the token belongs to (cross chain id or token key if cross chain id is empty)
tokenKey [string] - token unique key (chain - address)
chainId [int] - token's chain id
tokenAddress [string] - token's address
balance [string] - balance that the `account` has for token with `tokenKey`
communityId [string] - for community assets, unique identifier of a community, e.g. "0x6734235"
soulbound [bool] - for community assets, true for non-transferable tokens (listed but never sendable)
ownerToken [bool] - for community assets, true for the owner token (sent only via the ownership-transfer flow)
communityName [string] - for community assets, name of a community e.g. "Crypto Kitties"
communityIcon [url] - for community assets, community's icon url
websiteUrl [string] - token's website
description [string] - token's description
marketDetails [object] - contains market data
changePctHour [double] - percentage change hour
changePctDay [double] - percentage change day
changePct24hour [double] - percentage change 24 hrs
change24hour [double] - change 24 hrs
marketCap [object]
amount [double] - market capitalization value
symbol [string] - currency, eg. "USD"
displayDecimals [int] - decimals to display
stripTrailingZeroes [bool] - strip leading zeros
highDay [object]
amount [double] - the highest value for day
symbol [string] - currency, eg. "USD"
displayDecimals [int] - decimals to display
stripTrailingZeroes [bool] - strip leading zeros
lowDay [object]
amount [double] - the lowest value for day
symbol [string] - currency, eg. "USD"
displayDecimals [int] - decimals to display
stripTrailingZeroes [bool] - strip leading zeros
currencyPrice [object]
amount [double] - token's price
symbol [string] - currency, eg. "USD"
displayDecimals [int] - decimals to display
stripTrailingZeroes [bool] - strip leading zeros
detailsLoading [bool] - `true` if details are still being loaded
balance [double] - tokens balance is the commonly used unit, e.g. 1.2 for 1.2 ETH, used for sorting and computing market value
balanceText [string] - formatted and localized balance. This is not done internally because it may depend on many external factors
balanceLoading [bool] - true while at least one of the per-(chain, account) balances has not been fetched yet
error [string] - error message related to balance
marketDetailsAvailable [bool] - specifies if market datails are available for given token
marketDetailsLoading [bool] - specifies if market datails are available for given token
marketPrice [double] - specifies market price in currently used currency
marketChangePct24hour [double] - percentage price change in last 24 hours, e.g. 0.5 for 0.5% of price change
visible [bool] - determines if token is displayed or not
position [int] - token's position
canBeHidden [bool] - specifies if given token can be hidden (e.g. ETH should be always visible)
**/
property var model
// enables global loading state useful when real data are not yet available
property bool loading
// shows/hides list sorter
property bool sorterVisible
// allows/disables choosing custom sort order from a sorter
property bool customOrderAvailable
// switches configuring right click menu
property bool sendEnabled: true
property bool communitySendEnabled: false
property bool swapEnabled: true
property bool swapVisible: true
property bool communitySwapVisible: false
property string balanceError
// banner component to be displayed on top of the list
property alias bannerComponent: banner.sourceComponent
// global market data error, presented for all tokens expecting market data
property string marketDataError
// formatting function for fiat currency values
property var formatFiat: balance => `${balance.toLocaleCurrencyString(Qt.locale())}`
// formats a token's balance for display (moved out of the retired proxy chain;
// the consumer supplies the currency context)
property var formatBalance: (balance, key) => `${balance.toLocaleString(Qt.locale())} ${key}`
// returns an error message for a token given its contributing chain ids,
// or an empty string when there is none
property var chainsError: (chainIds) => ""
// sort intent — consumer wires this to the model's sortBy(roleName, order)
signal sortRequested(string roleName, int order)
signal sendRequested(string key)
signal receiveRequested(string key)
signal swapRequested(string key)
signal assetClicked(string key)
signal communityClicked(string communityKey)
signal hideRequested(string key)
signal hideCommunityAssetsRequested(string communityKey)
signal manageTokensRequested
function setSortOrder(order) {
d.sortOrder = order
}
function getSortOrder() {
return d.sortOrder
}
function getSortValue() {
return d.sortValue
}
function sortByValue(value) {
d.sortValue = value
}
QtObject {
id: d
readonly property int loadingItemsCount: 25
property int sortOrder: Qt.DescendingOrder
property int sortValue: -1
// Latched true once the source model has ever had rows. Keeps the list
// bound to the real model across periodic refreshes that toggle
// `loading`; the placeholder only ever shows before the first real data.
property bool everHadContent: false
// Latch off the source model's row count. Guard the null case with `&&`
// rather than optional chaining: the AOT-compiled mobile build drops the
// reactive dependency captured through `?.`, so the latch would never
// re-evaluate when rows arrive after creation.
readonly property int modelCount: !!root.model ? root.model.ModelCount.count : 0
onModelCountChanged: if (modelCount > 0) everHadContent = true
Component.onCompleted: if (modelCount > 0) everHadContent = true
// Emit the current sorter selection as an intent; separators carry an
// empty role name and are ignored.
function requestSort() {
const roleName = sortOrderComboBox.currentSortRoleName
if (!roleName)
return
root.sortRequested(roleName, sortOrderComboBox.currentSortOrder)
}
}
Connections {
target: sortOrderComboBox
function onCurrentSortRoleNameChanged() { d.requestSort() }
function onCurrentSortOrderChanged() { d.requestSort() }
}
contentItem: ColumnLayout {
ColumnLayout {
Layout.fillHeight: false
Layout.preferredHeight: root.sorterVisible ? implicitHeight : 0
opacity: root.sorterVisible ? 1 : 0
spacing: 20
visible: opacity > 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
Layout.fillHeight: false
spacing: Theme.halfPadding
StatusBaseText {
color: Theme.palette.baseColor1
font.pixelSize: Theme.additionalTextSize
text: qsTr("Sort by:")
}
SortOrderComboBox {
id: sortOrderComboBox
objectName: "cmbTokenOrder"
hasCustomOrderDefined: root.customOrderAvailable
Binding on currentIndex {
value: {
sortOrderComboBox.count
let id = sortOrderComboBox.indexOfValue(d.sortValue)
if (id === -1)
id = sortOrderComboBox.indexOfValue(SortOrderComboBox.TokenOrderAlpha)
return id
}
when: sortOrderComboBox.count > 0
}
onCurrentValueChanged: d.sortValue = sortOrderComboBox.currentValue
Binding on currentSortOrder {
value: d.sortOrder
}
onCurrentSortOrderChanged: d.sortOrder = sortOrderComboBox.currentSortOrder
model: [
{ value: SortOrderComboBox.TokenOrderCurrencyBalance,
text: qsTr("Asset balance value"), icon: "", sortRoleName: "marketBalance" },
{ value: SortOrderComboBox.TokenOrderBalance,
text: qsTr("Asset balance"), icon: "", sortRoleName: "balance" },
{ value: SortOrderComboBox.TokenOrderCurrencyPrice,
text: qsTr("Asset value"), icon: "", sortRoleName: "marketPrice" },
{ value: SortOrderComboBox.TokenOrder1DChange,
text: qsTr("1d change: balance value"), icon: "", sortRoleName: "change1DayFiat" },
{ value: SortOrderComboBox.TokenOrderAlpha,
text: qsTr("Asset name"), icon: "", sortRoleName: "name" },
{ value: SortOrderComboBox.TokenOrderCustom,
text: qsTr("Custom order"), icon: "", sortRoleName: "position" },
{ 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()
}
}
}
StatusDialogDivider { Layout.fillWidth: true }
}
Loader {
id: banner
Layout.fillWidth: true
}
DelegateModel {
id: regularModel
model: root.model ?? null
// Only the shell is built by the list's refill, which is one
// uninterruptible polish pass; the row itself incubates behind the
// shell's async Loader in metered bites.
delegate: TokenDelegateShell {
id: rowShell
objectName: `AssetView_TokenRowShell_${model.symbol}`
width: ListView.view.width
sourceComponent: TokenDelegate {
objectName: `AssetView_TokenListItem_${model.symbol}` // TODO: use model.key
width: rowShell.width
// chainIds arrives as a comma-separated string from the terminal model
readonly property var chainIdsList: {
const ids = model.chainIds
return (ids && ids.length) ? ids.split(",").map(Number) : []
}
name: model.name
icon: model.logoUri || Constants.tokenIcon(model.symbol, false)
balance: root.formatBalance(model.balance, model.key)
balanceLoading: model.balanceLoading
marketBalance: root.formatFiat(model.marketBalance)
marketDetailsAvailable: model.marketDetailsAvailable
marketDetailsLoading: model.marketDetailsLoading
marketCurrencyPrice: root.formatFiat(model.change1DayFiat)
marketChangePct24hour: model.marketChangePct24hour
communityId: model.communityId
communityName: model.communityName ?? ""
communityIcon: model.communityImage ?? ""
errorTooltipText_1: root.chainsError(chainIdsList)
errorTooltipText_2: root.marketDataError
errorMode: !!root.balanceError
errorIcon.tooltip.text: root.balanceError
onClicked: function (itemId, mouse) {
if (mouse.button === Qt.LeftButton)
root.assetClicked(model.key)
else if (mouse.button === Qt.RightButton)
tokenContextMenu.createObject(this, { model }).popup(mouse.x, mouse.y)
}
onCommunityClicked: (communityId) => root.communityClicked(model.communityId)
}
}
}
DelegateModel {
id: loadingModel
model: d.loadingItemsCount
delegate: LoadingTokenDelegate {
objectName: `AssetView_LoadingTokenDelegate_${model.index}`
width: ListView.view.width
}
}
StatusListView {
id: listView
objectName: "assetViewStatusListView"
Layout.fillWidth: true
Layout.fillHeight: true
// Operand order matters: once `everHadContent` latches true,
// `!d.everHadContent` is false and short-circuits `&&`, dropping
// `root.loading` from this binding's captured dependencies. The
// periodic `loading` toggles then no longer re-evaluate and re-assign
// the model — re-assigning even the same DelegateModel makes the view
// rebuild every delegate. Before first data it still shows the
// placeholder while `loading` is true.
model: (!d.everHadContent && root.loading) ? loadingModel : regularModel
section {
property: "isCommunity"
delegate: AssetsSectionDelegate {
width: parent.width
text: qsTr("Community minted")
onInfoButtonClicked: communityInfoPopup.createObject(this).open()
}
}
}
}
Component {
id: tokenContextMenu
AssetContextMenu {
required property var model
readonly property string key: model.key
readonly property string communityKey: model.communityId
readonly property bool isCommunity: !!model.isCommunity
onClosed: destroy()
sendEnabled: root.sendEnabled
&& (!isCommunity || root.communitySendEnabled)
&& !model.soulbound
&& !model.ownerToken
swapEnabled: root.swapEnabled
swapVisible: root.swapVisible && (!isCommunity || root.communitySwapVisible)
hideVisible: model.canBeHidden
communityHideVisible: isCommunity
onSendRequested: root.sendRequested(key)
onReceiveRequested: root.receiveRequested(key)
onSwapRequested: root.swapRequested(key)
onHideRequested: root.hideRequested(key)
onCommunityHideRequested: root.hideCommunityAssetsRequested(communityKey)
onManageTokensRequested: root.manageTokensRequested()
}
}
Component {
id: communityInfoPopup
CommunityAssetsInfoPopup {
destroyOnClose: true
}
}
}