diff --git a/storybook/PagesModel.qml b/storybook/PagesModel.qml index a360b18fd7..2fc8932fa0 100644 --- a/storybook/PagesModel.qml +++ b/storybook/PagesModel.qml @@ -1,4 +1,4 @@ -import QtQuick 2.14 +import QtQuick 2.15 ListModel { ListElement { @@ -125,6 +125,10 @@ ListModel { title: "TokenHoldersPanel" section: "Panels" } + ListElement { + title: "SortableTokenHoldersPanel" + section: "Panels" + } ListElement { title: "ProfileSocialLinksPanel" section: "Panels" @@ -309,6 +313,10 @@ ListModel { title: "StatusChatListItem" section: "Components" } + ListElement { + title: "SortableTokenHoldersList" + section: "Components" + } ListElement { title: "BrowserSettings" section: "Settings" diff --git a/storybook/pages/CommunityTokenViewPage.qml b/storybook/pages/CommunityTokenViewPage.qml index 8b6cd68a7d..211b31a14a 100644 --- a/storybook/pages/CommunityTokenViewPage.qml +++ b/storybook/pages/CommunityTokenViewPage.qml @@ -47,6 +47,10 @@ SplitView { chainIcon: ModelsData.networks.ethereum accountName: "helloworld" + tokenOwnersModel: TokenHoldersModel { + + } + onMintCollectible: logs.logEvent("CommunityTokenView::onMintCollectible: \n" + "artworkSource: " + artworkSource + "\n" + "name: " + name + "\n" diff --git a/storybook/pages/SortableTokenHoldersListPage.qml b/storybook/pages/SortableTokenHoldersListPage.qml new file mode 100644 index 0000000000..cc0b389a1f --- /dev/null +++ b/storybook/pages/SortableTokenHoldersListPage.qml @@ -0,0 +1,50 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 + +import AppLayouts.Chat.panels.communities 1.0 + +import Storybook 1.0 +import Models 1.0 + +SplitView { + id: root + + Logs { id: logs } + + orientation: Qt.Vertical + + TokenHoldersModel { + id: tokenHoldersModel + } + + Item { + SplitView.fillWidth: true + SplitView.fillHeight: true + + SortableTokenHoldersList { + id: holdersList + + anchors.fill: parent + anchors.margins: 50 + + model: TokenHoldersProxyModel { + sourceModel: tokenHoldersModel + + sortBy: holdersList.sortBy + sortOrder: holdersList.sorting === SortableTokenHoldersList.Sorting.Descending + ? Qt.DescendingOrder : Qt.AscendingOrder + } + + onClicked: logs.logEvent("holdersList.clicked: " + index) + } + } + + LogsAndControlsPanel { + id: logsAndControlsPanel + + SplitView.minimumHeight: 100 + SplitView.preferredHeight: 200 + + logsView.logText: logs.logText + } +} diff --git a/storybook/pages/SortableTokenHoldersPanelPage.qml b/storybook/pages/SortableTokenHoldersPanelPage.qml new file mode 100644 index 0000000000..a99269bd7e --- /dev/null +++ b/storybook/pages/SortableTokenHoldersPanelPage.qml @@ -0,0 +1,76 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import AppLayouts.Chat.panels.communities 1.0 + +import Storybook 1.0 +import Models 1.0 + +SplitView { + id: root + + Logs { id: logs } + + orientation: Qt.Vertical + + Item { + SplitView.fillWidth: true + SplitView.fillHeight: true + + SortableTokenHoldersPanel { + id: holdersPanel + + anchors.centerIn: parent + width: 568 + tokenName: "Aniversary" + + TokenHoldersModel { + id: tokenHoldersModel + } + + ListModel { + id: emptyModel + } + + model: emptyCheckBox.checked ? emptyModel : tokenHoldersModel + showRemotelyDestructMenuItem: remotelyDestructCheckBox.checked + + onViewProfileRequested: + logs.logEvent("onViewProfileRequested: " + address) + onViewMessagesRequested: + logs.logEvent("onViewMessagesRequested: " + address) + onAirdropRequested: + logs.logEvent("onAirdropRequested: " + address) + onRemoteDestructRequested: + logs.logEvent("onRemoteDestructRequested: " + address) + onKickRequested: + logs.logEvent("onKickRequested: " + address) + onBanRequested: + logs.logEvent("onBanRequested: " + address) + } + } + + LogsAndControlsPanel { + id: logsAndControlsPanel + + SplitView.minimumHeight: 100 + SplitView.preferredHeight: 200 + + logsView.logText: logs.logText + + ColumnLayout { + CheckBox { + id: emptyCheckBox + + text: "Empty" + } + CheckBox { + id: remotelyDestructCheckBox + + checked: true + text: "Show \"Remotely Destruct\" menu item" + } + } + } +} diff --git a/storybook/src/Models/TokenHoldersModel.qml b/storybook/src/Models/TokenHoldersModel.qml index 80babb9dce..0fd018dc46 100644 --- a/storybook/src/Models/TokenHoldersModel.qml +++ b/storybook/src/Models/TokenHoldersModel.qml @@ -5,30 +5,49 @@ ListModel { readonly property string image: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAYAAAAeP4ixAAAAlklEQVR4nOzW0QmDQBAG4SSkl7SUQlJGCrElq9F3QdjjVhh/5nv3cFhY9vUIYQiNITSG0BhCExPynn1gWf9bx498P7/ nzPcxEzGExhBdJGYihtAYQlO+tUZvqrPbqeudo5iJGEJjCE15a3VtodH3q2ImYgiNITTlTdG1nUZ5a92VITQxITFiJmIIjSE0htAYQrMHAAD//+wwFVpz+yqXAAAAAElFTkSuQmCC" readonly property var data: [ - { - name: "carmen.eth", - walletAddress: "0xb794f5450ba39494ce839613fffba74279579261", - imageSource: image, - amount: 15 - }, { name: "chris.eth", walletAddress: "0xb794f5ea0ba39494ce839613fffba74279579262", imageSource: image, - amount: 5 + amount: 5, + noOfMessages: 3123 + }, + { + name: "carmen.eth", + walletAddress: "0xb794f5450ba39494ce839613fffba74279579261", + imageSource: image, + amount: 15, + noOfMessages: 123 }, { name: "emily.eth", walletAddress: "0xb794f5ea0ba39494ce839613fffba74279579263", imageSource: image, - amount: 2 + amount: 2, + noOfMessages: 3 }, { name: "", walletAddress: "0xb794f5ea0ba39494ce839613fffba74279579268", imageSource: "", - amount: 1 + amount: 1, + noOfMessages: 0 + }, + { + name: "", + walletAddress: "0xc794f5ea0ba39494ce839613fffba74279579268", + imageSource: "", + amount: 11, + noOfMessages: 0 + }, + { + name: "", + walletAddress: "0xd794f5ea0ba39494ce839613fffba74279579268", + imageSource: "", + amount: 14, + noOfMessages: 0 } + ] Component.onCompleted: append(data) diff --git a/ui/app/AppLayouts/Chat/panels/communities/SortableTokenHoldersList.qml b/ui/app/AppLayouts/Chat/panels/communities/SortableTokenHoldersList.qml new file mode 100644 index 0000000000..36b910a73a --- /dev/null +++ b/ui/app/AppLayouts/Chat/panels/communities/SortableTokenHoldersList.qml @@ -0,0 +1,273 @@ +import QtQuick 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Controls 2.15 + +import StatusQ.Core 0.1 +import StatusQ.Core.Theme 0.1 +import StatusQ.Components 0.1 +import StatusQ.Core.Utils 0.1 as StatusQUtils + +import utils 1.0 + +/*! + \qmltype SortableTokenHoldersList + \inherits StatusListView + \brief Shows list of users or addresses with corrensponding numbers of + messages and holding amounts. + + Expected roles: name, walletAddress, imageSource, noOfMessages, amount + */ +StatusListView { + id: root + + enum SortBy { + None, Username, NoOfMessages, Holding + } + + enum Sorting { + Descending, Ascending + } + + readonly property alias sortBy: d.sortBy + readonly property alias sorting: d.sorting + + signal clicked(int index, var parent, var mouse) + + currentIndex: -1 + + component ColumnHeader: StatusSortableColumnHeader { + id: columnHeader + + leftPadding: 0 + rightPadding: 4 + + Connections { + target: d + + function onResetOtherHeaders(header) { + if (header !== columnHeader) + columnHeader.reset() + } + } + + onClicked: { + d.resetOtherHeaders(this) + + if (sorting === StatusSortableColumnHeader.Sorting.Ascending) + d.sorting = SortableTokenHoldersList.Sorting.Ascending + else if (sorting === StatusSortableColumnHeader.Sorting.Descending) + d.sorting = SortableTokenHoldersList.Sorting.Descending + } + } + + component NumberCell: StatusBaseText { + horizontalAlignment: Qt.AlignRight + + font.weight: Font.Medium + font.pixelSize: 13 + + color: Theme.palette.baseColor1 + elide: Qt.ElideRight + } + + QtObject { + id: d + + property int sortBy: SortableTokenHoldersList.SortBy.None + property int sorting: SortableTokenHoldersList.Sorting.Descending + + readonly property int red2Color: 4 + + signal resetOtherHeaders(var header) + } + + header: ItemDelegate { + width: ListView.view.width + + padding: 0 + horizontalPadding: Style.current.padding + + readonly property alias usernameHeaderWidth: usernameHeader.width + readonly property alias noOfMessagesHeaderWidth: noOfMessagesHeader.width + readonly property alias holdingHeaderWidth: holdingHeader.width + + contentItem: RowLayout { + id: row + + spacing: Style.current.padding + + RowLayout { + id: usernameHeader + + ColumnHeader { + text: qsTr("Username") + + traversalOrder: [ + StatusSortableColumnHeader.Sorting.NoSorting, + StatusSortableColumnHeader.Sorting.Ascending, + StatusSortableColumnHeader.Sorting.Descending + ] + + onClicked: { + if (sorting !== StatusSortableColumnHeader.Sorting.NoSorting) + d.sortBy = SortableTokenHoldersList.SortBy.Username + else + d.sortBy = SortableTokenHoldersList.SortBy.None + } + } + + Item { + Layout.fillWidth: true + } + } + + ColumnHeader { + id: noOfMessagesHeader + + text: qsTr("No. of messages") + + onClicked: { + if (sorting !== StatusSortableColumnHeader.Sorting.NoSorting) + d.sortBy = SortableTokenHoldersList.SortBy.NoOfMessages + else + d.sortBy = SortableTokenHoldersList.SortBy.None + } + } + + RowLayout { + id: holdingHeader + + Item { + Layout.preferredWidth: 25 + } + + ColumnHeader { + text: qsTr("Hodling") + + onClicked: { + if (sorting !== StatusSortableColumnHeader.Sorting.NoSorting) + d.sortBy = SortableTokenHoldersList.SortBy.Holding + else + d.sortBy = SortableTokenHoldersList.SortBy.None + } + } + } + } + } + + delegate: ItemDelegate { + id: delegate + + padding: 0 + horizontalPadding: Style.current.padding + + topPadding: showSeparator ? 10 : 0 + + readonly property string name: model.name + + readonly property bool isFirstRowAddress: { + if (model.name !== "") + return false + + const item = root.itemAtIndex(index - 1) + return item && item.name + } + + readonly property bool showSeparator: isFirstRowAddress + && root.sortBy === SortableTokenHoldersList.SortBy.Username + + width: ListView.view.width + + background: Item { + Rectangle { + anchors.fill: parent + + anchors.topMargin: delegate.topPadding + + radius: Style.current.radius + color: (delegate.hovered || delegate.ListView.isCurrentItem) + ? Theme.palette.baseColor2 : "transparent" + } + + Rectangle { + visible: delegate.showSeparator + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: delegate.topPadding / 2 + + height: 1 + color: Theme.palette.baseColor2 + } + } + + contentItem: Item { + implicitWidth: delegateRow.implicitWidth + implicitHeight: delegateRow.implicitHeight + + RowLayout { + id: delegateRow + + spacing: Style.current.padding + + StatusListItem { + id: listItem + + readonly property bool unknownHolder: model.name === "" + readonly property string formattedTitle: unknownHolder + ? "?" : model.name + + readonly property string addressElided: + StatusQUtils.Utils.elideText( + model.walletAddress, 6, 3).replace("0x", "0×") + + Layout.preferredWidth: root.headerItem.usernameHeaderWidth + + color: "transparent" + + leftPadding: 0 + rightPadding: 0 + sensor.enabled: false + title: unknownHolder ? addressElided : model.name + + statusListItemIcon.name: "?" + + subTitle: unknownHolder ? "" : addressElided + + statusListItemSubTitle.font.pixelSize: Theme.asideTextFontSize + statusListItemSubTitle.lineHeightMode: Text.FixedHeight + statusListItemSubTitle.lineHeight: 14 + + asset.name: model.imageSource + asset.isImage: true + asset.isLetterIdenticon: unknownHolder + asset.color: Theme.palette.userCustomizationColors[d.red2Color] + } + + NumberCell { + Layout.preferredWidth: root.headerItem.noOfMessagesHeaderWidth + + text: model.name + ? LocaleUtils.numberToLocaleString(model.noOfMessages) + : "-" + } + + NumberCell { + Layout.preferredWidth: root.headerItem.holdingHeaderWidth + + text: LocaleUtils.numberToLocaleString(model.amount) + } + } + } + + MouseArea { + anchors.fill: parent + + acceptedButtons: Qt.AllButtons + cursorShape: Qt.PointingHandCursor + + onClicked: root.clicked(model.index, delegate, mouse) + } + } +} diff --git a/ui/app/AppLayouts/Chat/panels/communities/SortableTokenHoldersPanel.qml b/ui/app/AppLayouts/Chat/panels/communities/SortableTokenHoldersPanel.qml new file mode 100644 index 0000000000..10e4b2be5d --- /dev/null +++ b/ui/app/AppLayouts/Chat/panels/communities/SortableTokenHoldersPanel.qml @@ -0,0 +1,188 @@ +import QtQuick 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Controls 2.15 + +import StatusQ.Controls 0.1 +import StatusQ.Core 0.1 +import StatusQ.Core.Theme 0.1 +import StatusQ.Core.Utils 0.1 +import StatusQ.Popups 0.1 + +import utils 1.0 +import shared.controls 1.0 + +Control { + id: root + + // Expected roles: name, walletAddress, imageSource, amount + property var model + + property string tokenName + property bool showRemotelyDestructMenuItem: true + + readonly property alias sortBy: holdersList.sortBy + readonly property alias sorting: holdersList.sorting + + signal viewProfileRequested(string address) + signal viewMessagesRequested(string address) + signal airdropRequested(string address) + signal remoteDestructRequested(string address) + signal kickRequested(string address) + signal banRequested(string address) + + TokenHoldersProxyModel { + id: proxyModel + + sourceModel: root.model + searchText: searcher.text + + sortBy: holdersList.sortBy + sortOrder: holdersList.sorting === SortableTokenHoldersList.Sorting.Descending + ? Qt.DescendingOrder : Qt.AscendingOrder + } + + QtObject { + id: d + + readonly property int red2Color: 4 + } + + contentItem: ColumnLayout { + anchors.fill: parent + spacing: 0 + + StatusBaseText { + Layout.fillWidth: true + + wrapMode: Text.Wrap + font.pixelSize: Style.current.primaryTextFontSize + color: Theme.palette.baseColor1 + + text: qsTr("%1 token holders").arg(root.tokenName) + } + + SearchBox { + id: searcher + + Layout.fillWidth: true + Layout.topMargin: 12 + + visible: !root.empty + + topPadding: 0 + bottomPadding: 0 + minimumHeight: 36 // by design + maximumHeight: minimumHeight + placeholderText: qsTr("Search hodlers") + } + + StatusBaseText { + Layout.fillWidth: true + Layout.topMargin: 12 + + wrapMode: Text.Wrap + font.pixelSize: Style.current.primaryTextFontSize + color: Theme.palette.baseColor1 + + visible: searcher.text.length > 0 + + text: (searcher.text.length > 0 && proxyModel.count > 0) + ? qsTr("Search results") : qsTr("No hodlers found") + } + + SortableTokenHoldersList { + id: holdersList + + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + Layout.topMargin: 20 + + model: proxyModel + + onClicked: { + if (mouse.button !== Qt.RightButton) + return + + const entry = ModelUtils.get(proxyModel, index) + const address = entry.walletAddress + const name = entry.name + + menu.rawAddress = name === "" + menu.currentAddress = address + menu.popup(parent, mouse.x, mouse.y) + + holdersList.currentIndex = index + } + } + } + + StatusMenu { + id: menu + + property string currentAddress + property bool rawAddress + + onClosed: holdersList.currentIndex = -1 + + StatusAction { + text: qsTr("View Profile") + icon.name: "profile" + enabled: !menu.rawAddress + + onTriggered: root.viewProfileRequested(menu.currentAddress) + } + + StatusAction { + text: qsTr("View Messages") + icon.name: "chat" + enabled: !menu.rawAddress + + onTriggered: root.viewMessagesRequested(menu.currentAddress) + } + + StatusAction { + text: qsTr("Airdrop") + icon.name: "airdrop" + + onTriggered: root.airdropRequested(menu.currentAddress) + } + + StatusMenuSeparator { + visible: remotelyDestructAction.enabled || kickAction.enabled + || banAction.enabled + } + + StatusAction { + id: remotelyDestructAction + + text: qsTr("Remotely destruct") + icon.name: "destroy" + enabled: root.showRemotelyDestructMenuItem + type: StatusBaseButton.Type.Danger + + onTriggered: root.remoteDestructRequested(menu.currentAddress) + } + + StatusAction { + id: kickAction + + text: qsTr("Kick") + icon.name: "warning" + enabled: !menu.rawAddress + type: StatusBaseButton.Type.Danger + + onTriggered: root.kickRequested(menu.currentAddress) + } + + StatusAction { + id: banAction + + text: qsTr("Ban") + icon.name: "cancel" + enabled: !menu.rawAddress + type: StatusBaseButton.Type.Danger + + onTriggered: root.banRequested(menu.currentAddress) + } + } +} diff --git a/ui/app/AppLayouts/Chat/panels/communities/TokenHoldersProxyModel.qml b/ui/app/AppLayouts/Chat/panels/communities/TokenHoldersProxyModel.qml new file mode 100644 index 0000000000..1bf005f978 --- /dev/null +++ b/ui/app/AppLayouts/Chat/panels/communities/TokenHoldersProxyModel.qml @@ -0,0 +1,63 @@ +import SortFilterProxyModel 0.2 + +SortFilterProxyModel { + id: root + + property string searchText + readonly property string searchTextLowerCase: searchText.toLowerCase() + + property int sortBy: SortableTokenHoldersList.SortBy.Username + property int sortOrder: Qt.AscendingOrder + + filters: ExpressionFilter { + expression: { + root.searchTextLowerCase + + const nameLowerCase = model.name.toLowerCase() + const addressLowerCase = model.walletAddress.toLowerCase() + + return nameLowerCase.includes(searchTextLowerCase) || + addressLowerCase.includes(searchTextLowerCase) + } + } + + sorters: [ + FilterSorter { + enabled: root.sortBy === SortableTokenHoldersList.SortBy.Username + + ValueFilter { + roleName: "name" + value: "" + inverted: true + } + + priority: 3 + }, + + RoleSorter { + enabled: root.sortBy === SortableTokenHoldersList.SortBy.Username + roleName: "name" + sortOrder: root.sortOrder + priority: 2 + }, + + RoleSorter { + enabled: root.sortBy === SortableTokenHoldersList.SortBy.Username + roleName: "walletAddress" + sortOrder: root.sortOrder + priority: 1 + }, + + RoleSorter { + enabled: root.sortBy === SortableTokenHoldersList.SortBy.NoOfMessages + roleName: "noOfMessages" + sortOrder: root.sortOrder + }, + + RoleSorter { + enabled: root.sortBy === SortableTokenHoldersList.SortBy.Holding + roleName: "amount" + sortOrder: root.sortOrder + } + ] +} diff --git a/ui/app/AppLayouts/Chat/panels/communities/qmldir b/ui/app/AppLayouts/Chat/panels/communities/qmldir index a4a00f17ea..16c82e8e64 100644 --- a/ui/app/AppLayouts/Chat/panels/communities/qmldir +++ b/ui/app/AppLayouts/Chat/panels/communities/qmldir @@ -9,5 +9,8 @@ JoinPermissionsOverlayPanel 1.0 JoinPermissionsOverlayPanel.qml MintTokensFooterPanel 1.0 MintTokensFooterPanel.qml PermissionConflictWarningPanel 1.0 PermissionConflictWarningPanel.qml PermissionQualificationPanel 1.0 PermissionQualificationPanel.qml +SortableTokenHoldersList 1.0 SortableTokenHoldersList.qml +SortableTokenHoldersPanel 1.0 SortableTokenHoldersPanel.qml TokenHoldersPanel 1.0 TokenHoldersPanel.qml +TokenHoldersProxyModel 1.0 TokenHoldersProxyModel.qml WarningPanel 1.0 WarningPanel.qml