feat(StatusChatList): Add drag and drop support of list items

This implements drag and drop capabilities of chat items within a `StatusChatList`.
The commit introduces a `DelegateModal` to visually reorder chat items
when they're being dragged and dropped onto a `DropArea`.

To persist the new order of chat items, various signals have been introduced to chat
list related components:

```qml
StatusChatList {

    onChatItemReordered: function (id, from, to) {
        // ...
    }
}

StatusChatListAndCategories {

    onChatItemReordered: function (categoryId, chatId, from, to) {
        // ...
    }
}
```

There's no such API on the `StatusChatListCategory` type because that one already
exposes its underlying `StatusChatList` via `chatList`, which makes the signal available.

Dragging and dropping chat items is disabled by default and needs to be turned on
using the `draggableItems` property:

```qml
StatusChatList {
    draggableItems: true
    ...
}
```
This commit is contained in:
B.Melnik 2021-07-28 14:08:56 +03:00 committed by Pascal Precht
parent c6952a89ae
commit c679854d7d
5 changed files with 254 additions and 96 deletions

View File

@ -467,6 +467,7 @@ Rectangle {
width: leftPanel.width
height: implicitHeight > (leftPanel.height - 64) ? implicitHeight + 8 : leftPanel.height - 64
draggableItems: true
chatList.model: models.demoCommunityChatListItems
categoryList.model: models.demoCommunityCategoryItems

View File

@ -13,6 +13,7 @@ QtObject {
unreadMessagesCount: 0
mentionsCount: 0
color: "blue"
position: 0
}
ListElement {
chatId: "1"
@ -22,6 +23,7 @@ QtObject {
color: "red"
unreadMessagesCount: 1
mentionsCount: 1
position: 1
}
ListElement {
chatId: "2"
@ -32,6 +34,7 @@ QtObject {
identicon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAYAAAAeP4ixAAAAlklEQVR4nOzW0QmDQBAG4SSkl7SUQlJGCrElq9F3QdjjVhh/5nv3cFhY9vUIYQiNITSG0Bh
CExPynn1gWf9bx498P7/nzPcxEzGExhBdJGYihtAYQlO+tUZvqrPbqeudo5iJGEJjCE15a3VtodH3q2ImYgiNITTlTdG1nUZ5a92VITQxITFiJmIIjSE0htAYQrMHAAD//+wwFVpz+yqXAAAAAElFTkSuQmCC"
unreadMessagesCount: 0
position: 2
}
ListElement {
chatId: "3"
@ -40,6 +43,7 @@ CExPynn1gWf9bx498P7/nzPcxEzGExhBdJGYihtAYQlO+tUZvqrPbqeudo5iJGEJjCE15a3VtodH3q2I
muted: false
color: "purple"
unreadMessagesCount: 0
position: 3
}
ListElement {
chatId: "4"
@ -48,6 +52,7 @@ CExPynn1gWf9bx498P7/nzPcxEzGExhBdJGYihtAYQlO+tUZvqrPbqeudo5iJGEJjCE15a3VtodH3q2I
muted: true
color: "Orange"
unreadMessagesCount: 0
position: 4
}
ListElement {
chatId: "5"
@ -56,6 +61,7 @@ CExPynn1gWf9bx498P7/nzPcxEzGExhBdJGYihtAYQlO+tUZvqrPbqeudo5iJGEJjCE15a3VtodH3q2I
muted: false
color: "green"
unreadMessagesCount: 0
position: 5
}
}
@ -68,6 +74,7 @@ CExPynn1gWf9bx498P7/nzPcxEzGExhBdJGYihtAYQlO+tUZvqrPbqeudo5iJGEJjCE15a3VtodH3q2I
muted: false
unreadMessagesCount: 0
color: "orange"
position: 0
}
ListElement {
chatId: "1"
@ -77,6 +84,7 @@ CExPynn1gWf9bx498P7/nzPcxEzGExhBdJGYihtAYQlO+tUZvqrPbqeudo5iJGEJjCE15a3VtodH3q2I
unreadMessagesCount: 0
color: "orange"
categoryId: "public"
position: 0
}
ListElement {
chatId: "2"
@ -86,6 +94,7 @@ CExPynn1gWf9bx498P7/nzPcxEzGExhBdJGYihtAYQlO+tUZvqrPbqeudo5iJGEJjCE15a3VtodH3q2I
unreadMessagesCount: 0
color: "orange"
categoryId: "public"
position: 1
}
ListElement {
chatId: "3"
@ -95,6 +104,7 @@ CExPynn1gWf9bx498P7/nzPcxEzGExhBdJGYihtAYQlO+tUZvqrPbqeudo5iJGEJjCE15a3VtodH3q2I
unreadMessagesCount: 0
color: "orange"
categoryId: "dev"
position: 0
}
}

View File

@ -1,4 +1,6 @@
import QtQuick 2.13
import QtQml.Models 2.14
import QtQuick.Controls 2.13 as QC
import StatusQ.Core 0.1
import StatusQ.Core.Theme 0.1
@ -13,7 +15,8 @@ Column {
property string categoryId: ""
property string selectedChatId: ""
property alias chatListItems: statusChatListItems
property alias chatListItems: delegateModel
property bool draggableItems: false
property Component popupMenu
@ -23,6 +26,19 @@ Column {
signal chatItemSelected(string id)
signal chatItemUnmuted(string id)
signal chatItemReordered(string id, int from, int to)
function getAbsolutePosition(node) {
var returnPos = {};
returnPos.x = 0;
returnPos.y = 0;
if (node !== undefined && node !== null) {
var parentValue = getAbsolutePosition(node.parent);
returnPos.x = parentValue.x + node.x;
returnPos.y = parentValue.y + node.y;
}
return returnPos;
}
onPopupMenuChanged: {
if (!!popupMenu) {
@ -30,75 +46,189 @@ Column {
}
}
Repeater {
id: statusChatListItems
delegate: StatusChatListItem {
DelegateModel {
id: delegateModel
id: statusChatListItem
delegate: Item {
id: draggable
width: statusChatListItem.width
height: statusChatListItem.height
property string profileImage: ""
property alias chatListItem: statusChatListItem
Component.onCompleted: {
if (typeof statusChatList.profileImageFn === "function") {
profileImage = statusChatList.profileImageFn(model.chatId || model.id) || ""
}
}
chatId: model.chatId || model.id
name: !!statusChatList.chatNameFn ? statusChatList.chatNameFn(model) : model.name
type: model.chatType
muted: !!model.muted
hasUnreadMessages: !!model.hasUnreadMessages || model.unviewedMessagesCount > 0
hasMention: model.mentionsCount > 0
badge.value: model.chatType === StatusChatListItem.Type.OneToOneChat ?
model.unviewedMessagesCount || 0 :
model.mentionsCount || 0
selected: (model.chatId || model.id) === statusChatList.selectedChatId
icon.color: model.color || ""
image.isIdenticon: !!!profileImage && !!!model.identityImage && !!model.identicon
image.source: profileImage || model.identityImage || model.identicon || ""
onClicked: {
if (mouse.button === Qt.RightButton && !!statusChatList.popupMenu) {
statusChatListItem.highlighted = true
let originalOpenHandler = popupMenuSlot.item.openHandler
let originalCloseHandler = popupMenuSlot.item.closeHandler
popupMenuSlot.item.openHandler = function () {
if (!!originalOpenHandler) {
originalOpenHandler((model.chatId || model.id))
}
}
popupMenuSlot.item.closeHandler = function () {
if (statusChatListItem) {
statusChatListItem.highlighted = false
}
if (!!originalCloseHandler) {
originalCloseHandler()
}
}
popupMenuSlot.item.popup(mouse.x + 4, statusChatListItem.y + mouse.y + 6)
popupMenuSlot.item.openHandler = originalOpenHandler
return
}
if (!statusChatListItem.selected) {
statusChatList.chatItemSelected(model.chatId || model.id)
}
}
onUnmute: statusChatList.chatItemUnmuted(model.chatId || model.id)
visible: {
if (!!statusChatList.filterFn) {
return statusChatList.filterFn(model, statusChatList.categoryId)
}
return true
}
MouseArea {
id: dragSensor
anchors.fill: parent
cursorShape: active ? Qt.ClosedHandCursor : Qt.PointingHandCursor
hoverEnabled: true
pressAndHoldInterval: 150
enabled: statusChatList.draggableItems
property bool active: false
property real startY: 0
property real startX: 0
drag.target: draggedListItemLoader.item
drag.threshold: 0.1
drag.filterChildren: true
onPressed: {
startY = mouseY
startX = mouseX
}
onPressAndHold: active = true
onReleased: {
if (active) {
statusChatList.chatItemReordered(statusChatListItem.chatId, statusChatListItem.originalOrder, statusChatListItem.originalOrder)
}
active = false
}
onMouseYChanged: {
if ((Math.abs(startY - mouseY) > 1) && pressed) {
active = true
}
}
onMouseXChanged: {
if ((Math.abs(startX - mouseX) > 1) && pressed) {
active = true
}
}
StatusChatListItem {
id: statusChatListItem
property string profileImage: ""
opacity: dragSensor.active ? 0.0 : 1.0
Component.onCompleted: {
if (typeof statusChatList.profileImageFn === "function") {
profileImage = statusChatList.profileImageFn(model.chatId || model.id) || ""
}
}
originalOrder: model.position
chatId: model.chatId || model.id
categoryId: model.categoryId || ""
name: !!statusChatList.chatNameFn ? statusChatList.chatNameFn(model) : model.name
type: model.chatType
muted: !!model.muted
hasUnreadMessages: !!model.hasUnreadMessages || model.unviewedMessagesCount > 0
hasMention: model.mentionsCount > 0
badge.value: model.chatType === StatusChatListItem.Type.OneToOneChat ?
model.unviewedMessagesCount || 0 :
model.mentionsCount || 0
selected: (model.chatId || model.id) === statusChatList.selectedChatId
icon.color: model.color || ""
image.isIdenticon: !!!profileImage && !!!model.identityImage && !!model.identicon
image.source: profileImage || model.identityImage || model.identicon || ""
sensor.cursorShape: dragSensor.cursorShape
onClicked: {
if (mouse.button === Qt.RightButton && !!statusChatList.popupMenu) {
statusChatListItem.highlighted = true
let originalOpenHandler = popupMenuSlot.item.openHandler
let originalCloseHandler = popupMenuSlot.item.closeHandler
popupMenuSlot.item.openHandler = function () {
if (!!originalOpenHandler) {
originalOpenHandler((model.chatId || model.id))
}
}
popupMenuSlot.item.closeHandler = function () {
if (statusChatListItem) {
statusChatListItem.highlighted = false
}
if (!!originalCloseHandler) {
originalCloseHandler()
}
}
popupMenuSlot.item.popup(mouse.x + 4, statusChatListItem.y + mouse.y + 6)
popupMenuSlot.item.openHandler = originalOpenHandler
return
}
if (!statusChatListItem.selected) {
statusChatList.chatItemSelected(model.chatId || model.id)
}
}
onUnmute: statusChatList.chatItemUnmuted(model.chatId || model.id)
}
}
DropArea {
id: dropArea
width: dragSensor.active ? 0 : parent.width
height: dragSensor.active ? 0 : parent.height
keys: ["chat-item-category-" + statusChatListItem.categoryId]
onEntered: reorderDelay.start()
onDropped: statusChatList.chatItemReordered(statusChatListItem.chatId, drag.source.originalOrder, statusChatListItem.DelegateModel.itemsIndex)
Timer {
id: reorderDelay
interval: 100
repeat: false
onTriggered: {
if (dropArea.containsDrag) {
dropArea.drag.source.chatListItem.originalOrder = statusChatListItem.originalOrder
delegateModel.items.move(dropArea.drag.source.DelegateModel.itemsIndex, draggable.DelegateModel.itemsIndex)
}
}
}
}
Loader {
id: draggedListItemLoader
active: dragSensor.active
sourceComponent: StatusChatListItem {
property var globalPosition: statusChatList.getAbsolutePosition(draggable)
parent: QC.Overlay.overlay
sensor.cursorShape: dragSensor.cursorShape
Drag.active: dragSensor.active
Drag.hotSpot.x: width / 2
Drag.hotSpot.y: height / 2
Drag.keys: ["chat-item-category-" + categoryId]
Drag.source: draggable
Component.onCompleted: {
x = globalPosition.x
y = globalPosition.y
}
chatId: draggable.chatListItem.chatId
categoryId: draggable.chatListItem.categoryId
name: draggable.chatListItem.name
type: draggable.chatListItem.type
muted: draggable.chatListItem.muted
dragged: true
hasUnreadMessages: draggable.chatListItem.hasUnreadMessages
hasMention: draggable.chatListItem.hasMention
badge.value: draggable.chatListItem.badge.value
selected: draggable.chatListItem.selected
icon.color: draggable.chatListItem.icon.color
image.isIdenticon: draggable.chatListItem.image.isIdenticon
image.source: draggable.chatListItem.image.source
}
}
}
}
Repeater {
id: statusChatListItems
model: delegateModel
}
Loader {
id: popupMenuSlot
active: !!statusChatList.popupMenu

View File

@ -16,6 +16,7 @@ Item {
property alias chatList: statusChatList.chatListItems
property alias categoryList: statusChatListCategories
property alias sensor: sensor
property bool draggableItems: false
property Component categoryPopupMenu
property Component chatListPopupMenu
@ -23,6 +24,7 @@ Item {
signal chatItemSelected(string id)
signal chatItemUnmuted(string id)
signal chatItemReordered(string categoryId, string chatId, int from, int to)
signal categoryAddButtonClicked(string id)
onPopupMenuChanged: {
@ -54,14 +56,17 @@ Item {
StatusChatList {
id: statusChatList
anchors.horizontalCenter: parent.horizontalCenter
visible: !!chatListItems.model && chatListItems.count > 0
visible: chatListItems.count > 0
selectedChatId: statusChatListAndCategories.selectedChatId
onChatItemSelected: statusChatListAndCategories.chatItemSelected(id)
onChatItemUnmuted: statusChatListAndCategories.chatItemUnmuted(id)
onChatItemReordered: statusChatListAndCategories.chatItemReordered(categoryId, id, from, to)
draggableItems: statusChatListAndCategories.draggableItems
filterFn: function (model) {
return !!!model.categoryId
}
popupMenu: statusChatListAndCategories.chatListPopupMenu
}
Repeater {
@ -78,6 +83,8 @@ Item {
chatList.selectedChatId: statusChatListAndCategories.selectedChatId
chatList.onChatItemSelected: statusChatListAndCategories.chatItemSelected(id)
chatList.onChatItemUnmuted: statusChatListAndCategories.chatItemUnmuted(id)
chatList.onChatItemReordered: statusChatListAndCategories.chatItemReordered(model.categoryId, id, from, to)
chatList.draggableItems: statusChatListAndCategories.draggableItems
popupMenu: statusChatListAndCategories.categoryPopupMenu
chatListPopupMenu: statusChatListAndCategories.chatListPopupMenu

View File

@ -1,4 +1,8 @@
import QtQuick 2.13
import QtQml.Models 2.13
import QtQuick.Controls 2.13 as QC
import QtGraphicalEffects 1.13
import StatusQ.Core 0.1
import StatusQ.Core.Theme 0.1
import StatusQ.Components 0.1
@ -7,7 +11,10 @@ import StatusQ.Controls 0.1
Rectangle {
id: statusChatListItem
objectName: "chatItem"
property int originalOrder: -1
property string chatId: ""
property string categoryId: ""
property string name: ""
property alias badge: statusBadge
property bool hasUnreadMessages: false
@ -20,6 +27,8 @@ Rectangle {
property int type: StatusChatListItem.Type.PublicChat
property bool highlighted: false
property bool selected: false
property bool dragged: false
property alias sensor: sensor
signal clicked(var mouse)
signal unmute()
@ -46,11 +55,13 @@ Rectangle {
return sensor.containsMouse || highlighted ? Theme.palette.statusChatListItem.hoverBackgroundColor : Theme.palette.baseColor4
}
opacity: dragged ? 0.7 : 1
MouseArea {
id: sensor
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
hoverEnabled: true
@ -63,7 +74,7 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
sourceComponent: !!statusChatListItem.image.source.toString() ?
statusRoundedImageCmp : statusLetterIdenticonCmp
statusRoundedImageCmp : statusLetterIdenticonCmp
}
Component {
@ -89,8 +100,8 @@ Rectangle {
image.source: statusChatListItem.image.source
showLoadingIndicator: true
color: statusChatListItem.image.isIdenticon ?
Theme.palette.statusRoundedImage.backgroundColor :
"transparent"
Theme.palette.statusRoundedImage.backgroundColor :
"transparent"
border.width: statusChatListItem.image.isIdenticon ? 1 : 0
border.color: Theme.palette.directColor7
}
@ -114,27 +125,27 @@ Rectangle {
if (statusChatListItem.muted && !sensor.containsMouse && !statusChatListItem.highlighted) {
return 0.4
}
return statusChatListItem.hasMention ||
statusChatListItem.hasUnreadMessages ||
statusChatListItem.selected ||
statusChatListItem.highlighted ||
statusBadge.visible ||
sensor.containsMouse ? 1.0 : 0.7
return statusChatListItem.hasMention ||
statusChatListItem.hasUnreadMessages ||
statusChatListItem.selected ||
statusChatListItem.highlighted ||
statusBadge.visible ||
sensor.containsMouse ? 1.0 : 0.7
}
icon: {
switch (statusChatListItem.type) {
case StatusChatListItem.Type.PublicCat:
return Theme.palette.name == "light" ? "tiny/public-chat" : "tiny/public-chat-white"
break;
case StatusChatListItem.Type.GroupChat:
return Theme.palette.name == "light" ? "tiny/group" : "tiny/group-white"
break;
case StatusChatListItem.Type.CommunityChat:
return Theme.palette.name == "light" ? "tiny/channel" : "tiny/channel-white"
break;
default:
return Theme.palette.name == "light" ? "tiny/public-chat" : "tiny/public-chat-white"
case StatusChatListItem.Type.PublicCat:
return Theme.palette.name == "light" ? "tiny/public-chat" : "tiny/public-chat-white"
break;
case StatusChatListItem.Type.GroupChat:
return Theme.palette.name == "light" ? "tiny/group" : "tiny/group-white"
break;
case StatusChatListItem.Type.CommunityChat:
return Theme.palette.name == "light" ? "tiny/channel" : "tiny/channel-white"
break;
default:
return Theme.palette.name == "light" ? "tiny/public-chat" : "tiny/public-chat-white"
}
}
}
@ -144,30 +155,30 @@ Rectangle {
anchors.left: statusIcon.visible ? statusIcon.right : identicon.right
anchors.leftMargin: statusIcon.visible ? 1 : 8
anchors.right: mutedIcon.visible ? mutedIcon.left :
statusBadge.visible ? statusBadge.left : parent.right
statusBadge.visible ? statusBadge.left : parent.right
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
text: statusChatListItem.type === StatusChatListItem.Type.PublicChat &&
!statusChatListItem.name.startsWith("#") ?
"#" + statusChatListItem.name :
statusChatListItem.name
text: (statusChatListItem.type === StatusChatListItem.Type.PublicChat &&
!statusChatListItem.name.startsWith("#") ?
"#" + statusChatListItem.name :
statusChatListItem.name)
elide: Text.ElideRight
color: {
if (statusChatListItem.muted && !sensor.containsMouse && !statusChatListItem.highlighted) {
return Theme.palette.directColor5
}
return statusChatListItem.hasMention ||
statusChatListItem.hasUnreadMessages ||
statusChatListItem.selected ||
statusChatListItem.highlighted ||
sensor.containsMouse ||
statusBadge.visible ? Theme.palette.directColor1 : Theme.palette.directColor4
return statusChatListItem.hasMention ||
statusChatListItem.hasUnreadMessages ||
statusChatListItem.selected ||
statusChatListItem.highlighted ||
sensor.containsMouse ||
statusBadge.visible ? Theme.palette.directColor1 : Theme.palette.directColor4
}
font.weight: !statusChatListItem.muted &&
(statusChatListItem.hasMention ||
statusChatListItem.hasUnreadMessages ||
statusBadge.visible) ? Font.Bold : Font.Medium
(statusChatListItem.hasMention ||
statusChatListItem.hasUnreadMessages ||
statusBadge.visible) ? Font.Bold : Font.Medium
font.pixelSize: 15
}
@ -184,7 +195,7 @@ Rectangle {
MouseArea {
id: mutedIconSensor
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
cursorShape: Qt.PointingHandCursor
anchors.fill: parent
onClicked: statusChatListItem.unmute()
}
@ -207,6 +218,5 @@ Rectangle {
border.color: color
visible: statusBadge.value > 0
}
}
}