Files
status-app/ui/app/mainui/sectionLoaders/CommunityChatLoader.qml
T
Alex Jbanca 68eb973c84 perf(chat): lazy, async member loading for user list and mention suggstions (#21920)
* perf(chat): lazy, async member loading for user list and mention suggestions

The members panel builds on first show only (latched), asynchronously
behind MembersListSkeleton — it must never delay a chat switch. Once
built, UserListPanel binds its users model only while the right panel is
actually visible, and it adopts the shared MembersPanelHeader introduced
with the skeletons. The mention suggestions adaptor stays detached from
the members model until the first mention entry (short wire delay,
loading skeleton in the suggestion box meanwhile; the latch drops on
usersModel swap so chat switches never pay for sorting the members
model). Together these were ~1.5s of the chat-switch freeze on large
communities.

* fixup! perf(chat): lazy, async member loading for user list and mention suggestions

The members panel is shared across the section's chats, so once built it
stayed visible through a chat switch: it showed the previous chat's
members while sorting the incoming model on the switch frame. A latch
drops it back behind MembersListSkeleton on every active-chat change and
re-arms on a short delay, the same shape as the mention suggestions
wiring. rightPanelReady now follows the loader it was named after.

* fixup! perf(chat): lazy, async member loading for user list and mention suggestions

Keying the latch on the active chat id dropped it on every switch, but a
community channel without permissions hands the view the *same* members
model object — so the panel was torn down and fully re-sorted for nothing,
on the dominant path. Key it on the model identity instead: same object,
same members, no work; different object, the protection the latch is for.

rightPanelReady must fold the latch in, or the chrome retires its members
skeleton exactly while the panel is showing one. membersWireDelay moves
into `d` (tests reach it through the view's private object), and the
panel's own skeleton is released like the others rather than kept alive
and hidden.

The switch test now targets the permissioned channel, whose model really
differs, and a new test pins that the shared-model switch costs nothing.

* fixup! perf(chat): lazy, async member loading for user list and mention suggestions

Extract the fully-wired CommunityChatLoader test harness (mock stack,
loader wiring, type-prefix finders) into a shared helpers component.
The members-skeleton suite is the first consumer; the community section
suite above adopts it at the next stack rebase, removing ~90 duplicated
lines per suite.

* chore: update translations

* perf: sorted users_model and PR comments

* perf: Avoid swapping skeleton for the real view when swipeview animations are running

* chore: update translations

* chore: update translations
2026-08-22 08:13:52 +03:00

415 lines
17 KiB
QML

import QtQml
import QtQuick
import StatusQ.Core.Theme
import StatusQ.Core.Utils as SQUtils
import StatusQ.Core.Backpressure
import StatusQ.Layout
import utils
import shared.stores as SharedStores
import shared.stores.send
import AppLayouts.stores as AppStores
import AppLayouts.stores.Messaging as MessagingStores
import AppLayouts.Communities.stores
import AppLayouts.Communities.panels
import AppLayouts.Chat.panels
import AppLayouts.Chat.stores as ChatStores
import AppLayouts.Profile.stores as ProfileStores
import AppLayouts.Wallet.stores as WalletStores
import mainui.adaptors
import mainui.sectionLoaders
Loader {
id: root
// Stores
required property AppStores.RootStore rootStore
required property AppStores.ContactsStore contactsStore
required property AppStores.AccountSettingsStore accountSettingsStore
required property AppStores.FeatureFlagsStore featureFlagsStore
required property SharedStores.RootStore sharedRootStore
required property SharedStores.CurrenciesStore currencyStore
required property SharedStores.CommunityTokensStore communityTokensStore
required property SharedStores.NetworkConnectionStore networkConnectionStore
required property SharedStores.NetworksStore networksStore
required property TransactionStore transactionStore
required property WalletStores.TokensStore tokensStore
required property WalletStores.WalletAssetsStore walletAssetsStore
required property ProfileStores.AdvancedStore advancedStore
required property CommunitiesStore communitiesStore
required property MessagingStores.MessagingRootStore messagingRootStore
required property ChatStores.CreateChatPropertiesStore createChatPropertiesStore
required property ContactsModelAdaptor contactsAdaptor
required property HandlersManagerLoader popupHandler
required property Loader emojiPopupLoader
required property Loader stickersPopupLoader
// Per-community inputs
required property string sectionId
required property var sectionItemModel
required property bool createChatViewOpened
required property bool isPortraitMode
property bool navToMsgDetails: root.rootStore.navToMsgDetails
property real leftPanelWidthOverride: 0
signal openAppSearchRequested()
// Back-navigation contract for AppMain's back chain. The chrome is
// interactive while the section item still incubates, so the loader must
// answer for it during that phase; once loaded, the item leads.
readonly property bool canGoBack: root.item?.canGoBack
?? chromeLoader.item?.canGoBack ?? false
function tryGoBack() {
if (root.item && typeof root.item.tryGoBack === "function")
return root.item.tryGoBack()
return chromeLoader.item?.tryGoBack() ?? false
}
asynchronous: true
// Host-driven gate for the chrome and its skeletons: with one loader
// instantiated per community, building the full chrome plus four live
// skeletons for never-visited sections is pure startup waste, but only
// the host knows which delegate is (or is about to become) the shown
// one — including during startup, while the app is still covered and
// nothing is effectively visible yet. Defaults to true so a standalone
// instance behaves like a plain, always-chromed loader.
property bool chromeNeeded: true
// The section chrome is owned by the loader: it shows instantly with
// skeleton panels and swaps in the real panels produced by ChatView
// (LayoutItemProxy retarget) as each one finishes incubating. Each slot is
// gated on its own readiness flag, not on `root.item`: ChatView's panels
// exist as soon as the section loads, but an asynchronous one is an empty
// Loader until it is ready, and retargeting the proxy to it releases the
// skeleton (setParentItem(null) + setVisible(false)) and paints nothing.
// The chrome is hidden while ChatLayout shows a full-page view
// (join/banned/offline community view or the community settings page).
Loader {
id: chromeLoader
anchors.fill: parent
active: root.chromeNeeded
sourceComponent: StatusSectionLayout {
objectName: "sectionChrome"
visible: !root.item || !root.item.ownsFullPage
headerContent: headerGate.up ? root.item.headerContent : headerSkeleton
leftPanel: leftPanelGate.up ? root.item.leftPanel : channelsSkeleton
centerPanel: centerPanelGate.up ? root.item.centerPanel : chatSkeleton
rightPanel: rightPanelGate.up ? root.item.rightPanel : membersSkeleton
showRightPanel: root.item?.showRightPanel ?? root.accountSettingsStore.showUsersList
subsectionHistory: root.item?.viewSubsectionHistory ?? null
leftPanelWidthOverride: root.leftPanelWidthOverride
onPanelSwitchStarted: d.panelSwitchOngoing = true
onPanelSwitchEnded: d.panelSwitchOngoing = false
}
}
// One gate per chrome slot: skeleton→panel promotion waits out the
// chrome's panel-switch animation.
PanelSwapGate {
id: headerGate
ready: root.item?.headerReady ?? false
switchOngoing: d.panelSwitchOngoing
}
PanelSwapGate {
id: leftPanelGate
ready: root.item?.leftPanelReady ?? false
switchOngoing: d.panelSwitchOngoing
}
PanelSwapGate {
id: centerPanelGate
ready: root.item?.centerPanelReady ?? false
switchOngoing: d.panelSwitchOngoing
}
PanelSwapGate {
id: rightPanelGate
ready: root.item?.rightPanelReady ?? false
switchOngoing: d.panelSwitchOngoing
}
// Skeleton slot items carry the same page paddings as the real panels.
// Each skeleton lives behind a Loader gated on its slot: an alive
// invisible skeleton re-evaluates its tile geometry bindings on every
// resize for the lifetime of the section.
Loader {
id: headerSkeleton
objectName: "headerSkeleton"
active: root.chromeNeeded && !headerGate.up
visible: active
sourceComponent: ChatHeaderSkeleton {}
}
Loader {
id: channelsSkeleton
objectName: "leftPanelSkeleton"
active: root.chromeNeeded && !leftPanelGate.up
visible: active
sourceComponent: CommunityChannelsSkeleton {
// The community identity is known before the section loads, so
// the real header shows immediately above the skeleton rows
name: root.sectionItemModel?.name ?? ""
membersCount: root.sectionItemModel?.joinedMembersCount ?? 0
image: root.sectionItemModel?.image ?? ""
color: root.sectionItemModel?.color ?? "transparent"
onShareOwnProfileRequested: Global.shareProfileDialogRequested(root.contactsStore.myPublicKey)
}
}
Loader {
id: chatSkeleton
objectName: "centerPanelSkeleton"
active: root.chromeNeeded && !centerPanelGate.up
visible: active
sourceComponent: MessagesChatSkeleton {
anchors.fill: parent
anchors.margins: Theme.padding
anchors.leftMargin: Theme.xlPadding
anchors.rightMargin: Theme.xlPadding
}
}
Loader {
id: membersSkeleton
objectName: "rightPanelSkeleton"
active: root.chromeNeeded && !rightPanelGate.up
visible: active
sourceComponent: MembersListSkeleton {}
}
onNavToMsgDetailsChanged: {
if (root.item && root.item.navToMsgDetails !== root.navToMsgDetails) {
root.item.navToMsgDetails = root.navToMsgDetails
}
}
// TODO: refactor this into a single shot function that navigates the view
// The bindings are getting messy
Binding {
when: !!root.item
root.navToMsgDetails: root.item.navToMsgDetails || root.rootStore.navToMsgDetails
}
Component {
id: chatRootStoreComp
ChatStores.RootStore {
contactsStore: root.contactsStore
currencyStore: root.currencyStore
communityTokensStore: root.communityTokensStore
openCreateChat: root.createChatViewOpened
isChatSectionModule: false
communityId: root.sectionId
}
}
QtObject {
id: d
readonly property url url: QmlCompiler.chatUrl
property ChatStores.RootStore chatRootStore: null
property var newCommunityStore: null
property int pendingSettingsSection: -1
property int pendingSettingsSubsection: -1
property bool showUsersPanel: root.accountSettingsStore.showUsersList
property int pendingViewIndex: -1
// The view switch may arrive while the section still incubates —
// queue it and replay on load, like the settings subsection below
function openCommunityView(index) {
if (root.item) {
root.item.currentIndex = index
return
}
pendingViewIndex = index
}
// The portrait chrome animates panel switches and brackets them with
// panelSwitchStarted/Ended: a panel that becomes ready mid-slide
// keeps its skeleton (via its PanelSwapGate) until the slide ends,
// or the swap frame stutters it.
property bool panelSwitchOngoing: false
function clearStores() {
pendingSettingsSection = -1
pendingSettingsSubsection = -1
pendingViewIndex = -1
if (d.chatRootStore) {
d.chatRootStore.destroy()
d.chatRootStore = null
}
if (d.newCommunityStore) {
d.newCommunityStore.destroy()
d.newCommunityStore = null
}
}
function createStores() {
if (!d.chatRootStore)
d.chatRootStore = chatRootStoreComp.createObject(root)
if (!d.newCommunityStore)
d.newCommunityStore = root.messagingRootStore.createCommunityRootStore(root, root.sectionId)
}
function applyPendingCommunitySettingsSubsection() {
if (!root.item ||
pendingSettingsSection === -1 ||
pendingSettingsSubsection === -1) {
return
}
root.item.switchToCommunitySettingsSubsection(pendingSettingsSection,
pendingSettingsSubsection)
pendingSettingsSection = -1
pendingSettingsSubsection = -1
}
function openCommunitySettingsSubsection(subsection, subsectionItem) {
pendingSettingsSection = subsection
pendingSettingsSubsection = subsectionItem
applyPendingCommunitySettingsSubsection()
}
}
function loadSection() {
if (!root.active)
return
if (!!root.item)
return
d.createStores()
setSource(d.url, {
isChatView: false,
visible: false,
sectionLayout: Qt.binding(() => chromeLoader.item),
showUsersList: Qt.binding(() => d.showUsersPanel),
emojiPopup: Qt.binding(() => root.emojiPopupLoader.item),
stickersPopup: Qt.binding(() => root.stickersPopupLoader.item),
sectionItemModel: Qt.binding(() => root.sectionItemModel),
createChatPropertiesStore: Qt.binding(() => root.createChatPropertiesStore),
communitiesStore: Qt.binding(() => root.communitiesStore),
communitySettingsDisabled: Qt.binding(() => !root.advancedStore.isManageCommunityOnTestModeEnabled
&& (root.rootStore.isProduction && root.networksStore.areTestNetworksEnabled)),
newCommunityStore: Qt.binding(() => d.newCommunityStore),
rootStore: Qt.binding(() => d.chatRootStore),
tokensStore: Qt.binding(() => root.tokensStore),
transactionStore: Qt.binding(() => root.transactionStore),
walletAssetsStore: Qt.binding(() => root.walletAssetsStore),
currencyStore: Qt.binding(() => root.currencyStore),
networksStore: Qt.binding(() => root.networksStore),
advancedStore: Qt.binding(() => root.advancedStore),
messageLinkSharingEnabled: Qt.binding(() => root.featureFlagsStore.messageLinkSharingEnabled
&& root.advancedStore.copyMessageLinksEnabled),
paymentRequestFeatureEnabled: Qt.binding(() => root.featureFlagsStore.paymentRequestEnabled),
extraLeftPadding: Qt.binding(() => root.isPortraitMode ? SQUtils.Utils.swipeIndicatorWidth : 0),
mutualContactsModel: Qt.binding(() => root.contactsAdaptor.mutualContacts),
gifUnfurlingEnabled: Qt.binding(() => root.sharedRootStore.gifUnfurlingEnabled),
neverAskAboutUnfurlingAgain: Qt.binding(() => root.sharedRootStore.neverAskAboutUnfurlingAgain),
usersModel: Qt.binding(() => d.chatRootStore.usersStore.usersModel),
myPublicKey: Qt.binding(() => root.contactsStore.myPublicKey),
navToMsgDetails: Qt.binding(() => root.rootStore.navToMsgDetails),
leftPanelWidthOverride: Qt.binding(() => root.leftPanelWidthOverride),
})
}
onLoaded: {
root.item.visible = true
if (d.pendingViewIndex !== -1) {
root.item.currentIndex = d.pendingViewIndex
d.pendingViewIndex = -1
}
d.applyPendingCommunitySettingsSubsection()
}
onActiveChanged: {
if (root.active) {
loadSection()
return
}
d.clearStores()
}
Component.onCompleted: {
Qt.callLater(() => QmlCompiler.precompile(QmlCompiler.chatUrl))
loadSection()
}
Connections {
target: Global
function onSwitchToCommunitySettings(communityId) {
if (communityId !== root.sectionId)
return
d.openCommunityView(1) // Settings
}
function onSwitchToCommunityChannelsView(communityId) {
if (communityId !== root.sectionId)
return
d.openCommunityView(0) // Channels
}
function onSwitchToCommunitySettingsSubsection(communityId, subsection, subsectionItem) {
if (communityId !== root.sectionId)
return
d.openCommunitySettingsSubsection(subsection, subsectionItem)
}
}
Connections {
target: root.item
ignoreUnknownSignals: true
function onShowUsersListRequested(show) {
d.showUsersPanel = show //optimistic; update settings after a short delay to avoid jank from the panel resize
Backpressure.setTimeout(root, 300, () => {
root.accountSettingsStore.setShowUsersList(show)
})
}
function onProfileButtonClicked() {
Global.changeAppSectionBySectionType(Constants.appSection.profile)
}
function onOpenAppSearch() { root.openAppSearchRequested() }
function onBuyStickerPackRequested(packId, price) {
root.popupHandler.buyStickerPack(packId, price)
}
function onTokenPaymentRequested(recipientAddress, tokenKey, rawAmount) {
root.popupHandler.openTokenPaymentRequest(recipientAddress, tokenKey, rawAmount)
}
function onSetNeverAskAboutUnfurlingAgain(neverAskAgain) {
root.sharedRootStore.setNeverAskAboutUnfurlingAgain(neverAskAgain)
}
function onOpenGifPopupRequest(params, cbOnGifSelected, cbOnClose) {
root.popupHandler.openGifs(params, cbOnGifSelected, cbOnClose)
}
function onChangeContactNicknameRequest(pubKey, nickname, displayName, isEdit) {
root.contactsStore.changeContactNickname(pubKey, nickname, displayName, isEdit)
}
function onRemoveTrustStatusRequest(pubKey) { root.contactsStore.removeTrustStatus(pubKey) }
function onDismissContactRequest(chatId, contactRequestId) {
root.contactsStore.dismissContactRequest(chatId, contactRequestId)
}
function onAcceptContactRequest(chatId, contactRequestId) {
root.contactsStore.acceptContactRequest(chatId, contactRequestId)
}
function onNavToMsgDetailsRequested(navigate) {
root.rootStore.setNavToMsgDetailsFlag(navigate)
}
}
}