From f7ea4c604c498af88bb635941d5780884c00d81b Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Sat, 19 Feb 2022 02:49:58 +0100 Subject: [PATCH] Use ListView without scrollview for messages That way we can autohide the scollbar if needed, it should fix some jumping issues, it makes it possible to flick on mobile, etc. Some related bugs: https://bugreports.qt.io/browse/QTBUG-75223 https://bugreports.qt.io/browse/QTBUG-44902 --- CMakeLists.txt | 2 + resources/qml/Avatar.qml | 1 + resources/qml/MessageView.qml | 981 +++++++++++++++++----------------- src/MainWindow.cpp | 2 + src/ui/NhekoEventObserver.cpp | 61 +++ src/ui/NhekoEventObserver.h | 27 + 6 files changed, 593 insertions(+), 481 deletions(-) create mode 100644 src/ui/NhekoEventObserver.cpp create mode 100644 src/ui/NhekoEventObserver.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 4c7a9ccd..a211b1f6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -336,6 +336,7 @@ set(SRC_FILES src/ui/MxcAnimatedImage.cpp src/ui/MxcMediaProxy.cpp src/ui/NhekoCursorShape.cpp + src/ui/NhekoEventObserver.cpp src/ui/NhekoDropArea.cpp src/ui/NhekoGlobalObject.cpp src/ui/RoomSettings.cpp @@ -532,6 +533,7 @@ qt5_wrap_cpp(MOC_HEADERS src/ui/MxcAnimatedImage.h src/ui/MxcMediaProxy.h src/ui/NhekoCursorShape.h + src/ui/NhekoEventObserver.h src/ui/NhekoDropArea.h src/ui/NhekoGlobalObject.h src/ui/RoomSettings.h diff --git a/resources/qml/Avatar.qml b/resources/qml/Avatar.qml index aca4a1a7..5875e309 100644 --- a/resources/qml/Avatar.qml +++ b/resources/qml/Avatar.qml @@ -105,6 +105,7 @@ Rectangle { id: mouseArea onSingleTapped: avatar.clicked(eventPoint) + dragThreshold: 0 } Ripple { diff --git a/resources/qml/MessageView.qml b/resources/qml/MessageView.qml index 2700cda6..e8cc9ed8 100644 --- a/resources/qml/MessageView.qml +++ b/resources/qml/MessageView.qml @@ -14,544 +14,564 @@ import QtQuick.Layouts 1.2 import QtQuick.Window 2.13 import im.nheko 1.0 -ScrollView { - clip: false - palette: Nheko.colors - padding: 8 - ScrollBar.horizontal.visible: false - ListView { - id: chat +Item { + id: chatRoot + property int padding: Nheko.paddingMedium - property int delegateMaxWidth: ((Settings.timelineMaxWidth > 100 && Settings.timelineMaxWidth < parent.availableWidth) ? Settings.timelineMaxWidth : parent.availableWidth) - parent.padding * 2 + property int availableWidth: width - displayMarginBeginning: height / 2 - displayMarginEnd: height / 2 - model: room - // reuseItems still has a few bugs, see https://bugreports.qt.io/browse/QTBUG-95105 https://bugreports.qt.io/browse/QTBUG-95107 - //onModelChanged: if (room) room.sendReset() - //reuseItems: true - boundsBehavior: Flickable.StopAtBounds - pixelAligned: true - spacing: 2 - verticalLayoutDirection: ListView.BottomToTop - onCountChanged: { - // Mark timeline as read - if (atYEnd && room) - model.currentIndex = 0; + ScrollBar { + id: scrollbar + interactive: !touchObserver.wasTouched + parent: chat.parent + anchors.top: parent.top + anchors.right: parent.right + anchors.bottom: parent.bottom + } - } + EventObserver { + id: touchObserver + anchors.fill: parent - Rectangle { - //closePolicy: Popup.NoAutoClose + ListView { + id: chat - id: messageActions - - property Item attached: null - property alias model: row.model - // use comma to update on scroll - property var attachedPos: chat.contentY, attached ? chat.mapFromItem(attached, attached ? attached.width - width : 0, -height) : null - readonly property int padding: Nheko.paddingSmall - - visible: Settings.buttonsInTimeline && !!attached && (attached.hovered || messageActionHover.hovered) - x: attached ? attachedPos.x : 0 - y: attached ? attachedPos.y : 0 - z: 10 - height: row.implicitHeight + padding * 2 - width: row.implicitWidth + padding * 2 - color: Nheko.colors.window - border.color: Nheko.colors.buttonText - border.width: 1 - radius: padding - - HoverHandler { - id: messageActionHover - - grabPermissions: PointerHandler.CanTakeOverFromAnything - } - - Row { - id: row - - property var model - - anchors.centerIn: parent - spacing: messageActions.padding - - Repeater { - model: Settings.recentReactions - - delegate: TextButton { - required property string modelData - - visible: chat.model ? chat.model.permissions.canSend(MtxEvent.Reaction) : false - - height: fontMetrics.height - font.family: Settings.emojiFont - - text: modelData - onClicked: { - room.input.reaction(row.model.eventId, modelData); - TimelineManager.focusMessageInput(); - } - } - } - - ImageButton { - id: editButton - - visible: !!row.model && row.model.isEditable - buttonTextColor: Nheko.colors.buttonText - width: 16 - hoverEnabled: true - image: ":/icons/icons/ui/edit.svg" - ToolTip.visible: hovered - ToolTip.delay: Nheko.tooltipDelay - ToolTip.text: qsTr("Edit") - onClicked: { - if (row.model.isEditable) - chat.model.editAction(row.model.eventId); - - } - } - - ImageButton { - id: reactButton - - visible: chat.model ? chat.model.permissions.canSend(MtxEvent.Reaction) : false - width: 16 - hoverEnabled: true - image: ":/icons/icons/ui/smile.svg" - ToolTip.visible: hovered - ToolTip.delay: Nheko.tooltipDelay - ToolTip.text: qsTr("React") - onClicked: emojiPopup.visible ? emojiPopup.close() : emojiPopup.show(reactButton, function(emoji) { - var event_id = row.model ? row.model.eventId : ""; - room.input.reaction(event_id, emoji); - TimelineManager.focusMessageInput(); - }) - } - - ImageButton { - id: replyButton - - visible: chat.model ? chat.model.permissions.canSend(MtxEvent.TextMessage) : false - width: 16 - hoverEnabled: true - image: ":/icons/icons/ui/reply.svg" - ToolTip.visible: hovered - ToolTip.delay: Nheko.tooltipDelay - ToolTip.text: qsTr("Reply") - onClicked: chat.model.replyAction(row.model.eventId) - } - - ImageButton { - id: optionsButton - - width: 16 - hoverEnabled: true - image: ":/icons/icons/ui/options.svg" - ToolTip.visible: hovered - ToolTip.delay: Nheko.tooltipDelay - ToolTip.text: qsTr("Options") - onClicked: messageContextMenu.show(row.model.eventId, row.model.type, row.model.isSender, row.model.isEncrypted, row.model.isEditable, "", row.model.body, optionsButton) - } - - } - - } - - ScrollHelper { - flickable: parent anchors.fill: parent - enabled: !Settings.mobileMode - } - Shortcut { - sequence: StandardKey.MoveToPreviousPage - onActivated: { - chat.contentY = chat.contentY - chat.height / 2; - chat.returnToBounds(); - } - } + property int delegateMaxWidth: ((Settings.timelineMaxWidth > 100 && Settings.timelineMaxWidth < chatRoot.availableWidth) ? Settings.timelineMaxWidth : chatRoot.availableWidth) - chatRoot.padding * 2 - scrollbar.width - Shortcut { - sequence: StandardKey.MoveToNextPage - onActivated: { - chat.contentY = chat.contentY + chat.height / 2; - chat.returnToBounds(); - } - } - - Shortcut { - sequence: StandardKey.Cancel - onActivated: { - if (chat.model.reply) - chat.model.reply = undefined; - else - chat.model.edit = undefined; - } - } - - Shortcut { - sequence: "Alt+Up" - onActivated: chat.model.reply = chat.model.indexToId(chat.model.reply ? chat.model.idToIndex(chat.model.reply) + 1 : 0) - } - - Shortcut { - sequence: "Alt+Down" - onActivated: { - var idx = chat.model.reply ? chat.model.idToIndex(chat.model.reply) - 1 : -1; - chat.model.reply = idx >= 0 ? chat.model.indexToId(idx) : null; - } - } - - Shortcut { - sequence: "Alt+F" - onActivated: { - if (chat.model.reply) { - var forwardMess = forwardCompleterComponent.createObject(timelineRoot); - forwardMess.setMessageEventId(chat.model.reply); - forwardMess.open(); - chat.model.reply = null; - } - } - } - - Shortcut { - sequence: "Ctrl+E" - onActivated: { - chat.model.edit = chat.model.reply; - } - } - - Connections { - function onFocusChanged() { - readTimer.running = TimelineManager.isWindowFocused; - } - - target: TimelineManager - } - - Timer { - id: readTimer - - // force current read index to update - onTriggered: { - if (chat.model) - chat.model.setCurrentIndex(chat.model.currentIndex); + displayMarginBeginning: height / 2 + displayMarginEnd: height / 2 + model: room + // reuseItems still has a few bugs, see https://bugreports.qt.io/browse/QTBUG-95105 https://bugreports.qt.io/browse/QTBUG-95107 + //onModelChanged: if (room) room.sendReset() + //reuseItems: true + boundsBehavior: Flickable.StopAtBounds + //pixelAligned: true + spacing: 2 + verticalLayoutDirection: ListView.BottomToTop + onCountChanged: { + // Mark timeline as read + if (atYEnd && room) model.currentIndex = 0; } - interval: 1000 - } - Component { - id: sectionHeader + ScrollBar.vertical: scrollbar - Column { - topPadding: userName_.visible? 4: 0 - bottomPadding: Settings.bubbles? (isSender? 0 : 2) : 3 - spacing: 8 - visible: (previousMessageUserId !== userId || previousMessageDay !== day || isStateEvent !== previousMessageIsStateEvent) - width: parentWidth - height: ((previousMessageDay !== day) ? dateBubble.height : 0) + (isStateEvent? 0 : userName.height +8 ) + anchors.rightMargin: scrollbar.interactive ? scrollbar.width : 0 - Label { - id: dateBubble + Rectangle { + //closePolicy: Popup.NoAutoClose - anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined - visible: room && previousMessageDay !== day - text: room ? room.formatDateSeparator(timestamp) : "" - color: Nheko.colors.text - height: Math.round(fontMetrics.height * 1.4) - width: contentWidth * 1.2 - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter + id: messageActions - background: Rectangle { - radius: parent.height / 2 - color: Nheko.colors.window - } + property Item attached: null + property alias model: row.model + // use comma to update on scroll + property var attachedPos: chat.contentY, attached ? chat.mapFromItem(attached, attached ? attached.width - width : 0, -height) : null + readonly property int padding: Nheko.paddingSmall + visible: Settings.buttonsInTimeline && !!attached && (attached.hovered || messageActionHover.hovered) + x: attached ? attachedPos.x : 0 + y: attached ? attachedPos.y : 0 + z: 10 + height: row.implicitHeight + padding * 2 + width: row.implicitWidth + padding * 2 + color: Nheko.colors.window + border.color: Nheko.colors.buttonText + border.width: 1 + radius: padding + + HoverHandler { + id: messageActionHover + + grabPermissions: PointerHandler.CanTakeOverFromAnything } Row { - height: userName_.height - spacing: 8 - visible: !isStateEvent && (!isSender || !Settings.bubbles) + id: row - Avatar { - id: messageUserAvatar + property var model - width: Nheko.avatarSize * (Settings.smallAvatars? 0.5 : 1) - height: Nheko.avatarSize * (Settings.smallAvatars? 0.5 : 1) - url: !room ? "" : room.avatarUrl(userId).replace("mxc://", "image://MxcImage/") - displayName: userName - userid: userId - onClicked: room.openUserProfile(userId) - ToolTip.visible: avatarHover.hovered - ToolTip.delay: Nheko.tooltipDelay - ToolTip.text: userid + anchors.centerIn: parent + spacing: messageActions.padding - HoverHandler { - id: avatarHover - } + Repeater { + model: Settings.recentReactions - } + delegate: TextButton { + required property string modelData - Connections { - function onRoomAvatarUrlChanged() { - messageUserAvatar.url = chat.model.avatarUrl(userId).replace("mxc://", "image://MxcImage/"); - } + visible: chat.model ? chat.model.permissions.canSend(MtxEvent.Reaction) : false - function onScrollToIndex(index) { - chat.positionViewAtIndex(index, ListView.Center); - } + height: fontMetrics.height + font.family: Settings.emojiFont - target: chat.model - } - - Label { - id: userName_ - - text: TimelineManager.escapeEmoji(userName) - color: TimelineManager.userColor(userId, Nheko.colors.base) - textFormat: Text.RichText - ToolTip.visible: displayNameHover.hovered - ToolTip.delay: Nheko.tooltipDelay - ToolTip.text: userId - - TapHandler { - onSingleTapped: chat.model.openUserProfile(userId) - } - - CursorShape { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - } - - HoverHandler { - id: displayNameHover - } - - } - - Label { - id: statusMsg - color: Nheko.colors.buttonText - text: Presence.userStatus(userId) - textFormat: Text.PlainText - elide: Text.ElideRight - width: chat.delegateMaxWidth - parent.spacing * 2 - userName.implicitWidth - Nheko.avatarSize - font.italic: true - - Connections { - target: Presence - - function onPresenceChanged(id) { - if (id == userId) statusMsg.text = Presence.userStatus(userId); + text: modelData + onClicked: { + room.input.reaction(row.model.eventId, modelData); + TimelineManager.focusMessageInput(); } } } - } + ImageButton { + id: editButton - } + visible: !!row.model && row.model.isEditable + buttonTextColor: Nheko.colors.buttonText + width: 16 + hoverEnabled: true + image: ":/icons/icons/ui/edit.svg" + ToolTip.visible: hovered + ToolTip.delay: Nheko.tooltipDelay + ToolTip.text: qsTr("Edit") + onClicked: { + if (row.model.isEditable) + chat.model.editAction(row.model.eventId); - } - - delegate: ItemDelegate { - id: wrapper - - required property double proportionalHeight - required property int type - required property string typeString - required property int originalWidth - required property string blurhash - required property string body - required property string formattedBody - required property string eventId - required property string filename - required property string filesize - required property string url - required property string thumbnailUrl - required property bool isOnlyEmoji - required property bool isSender - required property bool isEncrypted - required property bool isEditable - required property bool isEdited - required property bool isStateEvent - required property bool previousMessageIsStateEvent - required property string replyTo - required property string userId - required property string roomTopic - required property string roomName - required property string callType - required property var reactions - required property int trustlevel - required property int encryptionError - required property var timestamp - required property int status - required property int index - required property int relatedEventCacheBuster - required property string previousMessageUserId - required property string day - required property string previousMessageDay - required property string userName - property bool scrolledToThis: eventId === chat.model.scrollTarget && (y + height > chat.y + chat.contentY && y < chat.y + chat.height + chat.contentY) - - anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined - width: chat.delegateMaxWidth - height: section.active ? section.height + timelinerow.height : timelinerow.height - - hoverEnabled: true - - background: Rectangle { - id: scrollHighlight - - opacity: 0 - visible: true - z: 1 - enabled: false - color: Nheko.colors.highlight - - states: State { - name: "revealed" - when: wrapper.scrolledToThis - } - - transitions: Transition { - from: "" - to: "revealed" - - SequentialAnimation { - PropertyAnimation { - target: scrollHighlight - properties: "opacity" - easing.type: Easing.InOutQuad - from: 0 - to: 1 - duration: 500 } + } - PropertyAnimation { - target: scrollHighlight - properties: "opacity" - easing.type: Easing.InOutQuad - from: 1 - to: 0 - duration: 500 - } + ImageButton { + id: reactButton - ScriptAction { - script: chat.model.eventShown() - } + visible: chat.model ? chat.model.permissions.canSend(MtxEvent.Reaction) : false + width: 16 + hoverEnabled: true + image: ":/icons/icons/ui/smile.svg" + ToolTip.visible: hovered + ToolTip.delay: Nheko.tooltipDelay + ToolTip.text: qsTr("React") + onClicked: emojiPopup.visible ? emojiPopup.close() : emojiPopup.show(reactButton, function(emoji) { + var event_id = row.model ? row.model.eventId : ""; + room.input.reaction(event_id, emoji); + TimelineManager.focusMessageInput(); + }) + } + ImageButton { + id: replyButton + + visible: chat.model ? chat.model.permissions.canSend(MtxEvent.TextMessage) : false + width: 16 + hoverEnabled: true + image: ":/icons/icons/ui/reply.svg" + ToolTip.visible: hovered + ToolTip.delay: Nheko.tooltipDelay + ToolTip.text: qsTr("Reply") + onClicked: chat.model.replyAction(row.model.eventId) + } + + ImageButton { + id: optionsButton + + width: 16 + hoverEnabled: true + image: ":/icons/icons/ui/options.svg" + ToolTip.visible: hovered + ToolTip.delay: Nheko.tooltipDelay + ToolTip.text: qsTr("Options") + onClicked: messageContextMenu.show(row.model.eventId, row.model.type, row.model.isSender, row.model.isEncrypted, row.model.isEditable, "", row.model.body, optionsButton) } } } - Loader { - id: section - - property int parentWidth: parent.width - property string userId: wrapper.userId - property string previousMessageUserId: wrapper.previousMessageUserId - property string day: wrapper.day - property string previousMessageDay: wrapper.previousMessageDay - property bool previousMessageIsStateEvent: wrapper.previousMessageIsStateEvent - property bool isStateEvent: wrapper.isStateEvent - property bool isSender: wrapper.isSender - property string userName: wrapper.userName - property date timestamp: wrapper.timestamp - - z: 4 - active: previousMessageUserId !== undefined && previousMessageUserId !== userId || previousMessageDay !== day || previousMessageIsStateEvent !== isStateEvent - //asynchronous: true - sourceComponent: sectionHeader - visible: status == Loader.Ready + ScrollHelper { + flickable: parent + anchors.fill: parent } - TimelineRow { - id: timelinerow - - hovered: (wrapper.hovered && !messageActionHover.hovered) || (messageActions.model != undefined && messageActions.model.eventId == timelinerow.eventId) - - proportionalHeight: wrapper.proportionalHeight - type: chat.model, wrapper.type - typeString: wrapper.typeString - originalWidth: wrapper.originalWidth - blurhash: wrapper.blurhash - body: wrapper.body - formattedBody: wrapper.formattedBody - eventId: chat.model, wrapper.eventId - filename: wrapper.filename - filesize: wrapper.filesize - url: wrapper.url - thumbnailUrl: wrapper.thumbnailUrl - isOnlyEmoji: wrapper.isOnlyEmoji - isSender: wrapper.isSender - isEncrypted: wrapper.isEncrypted - isEditable: wrapper.isEditable - isEdited: wrapper.isEdited - isStateEvent: wrapper.isStateEvent - replyTo: wrapper.replyTo - userId: wrapper.userId - userName: wrapper.userName - roomTopic: wrapper.roomTopic - roomName: wrapper.roomName - callType: wrapper.callType - reactions: wrapper.reactions - trustlevel: wrapper.trustlevel - encryptionError: wrapper.encryptionError - timestamp: wrapper.timestamp - status: wrapper.status - relatedEventCacheBuster: wrapper.relatedEventCacheBuster - y: section.visible && section.active ? section.y + section.height : 0 + Shortcut { + sequence: StandardKey.MoveToPreviousPage + onActivated: { + chat.contentY = chat.contentY - chat.height / 2; + chat.returnToBounds(); + } } - onHoveredChanged: { - if (!Settings.mobileMode && hovered) { - if (!messageActionHover.hovered) { - messageActions.attached = timelinerow; - messageActions.model = timelinerow; + Shortcut { + sequence: StandardKey.MoveToNextPage + onActivated: { + chat.contentY = chat.contentY + chat.height / 2; + chat.returnToBounds(); + } + } + + Shortcut { + sequence: StandardKey.Cancel + onActivated: { + if (chat.model.reply) + chat.model.reply = undefined; + else + chat.model.edit = undefined; + } + } + + Shortcut { + sequence: "Alt+Up" + onActivated: chat.model.reply = chat.model.indexToId(chat.model.reply ? chat.model.idToIndex(chat.model.reply) + 1 : 0) + } + + Shortcut { + sequence: "Alt+Down" + onActivated: { + var idx = chat.model.reply ? chat.model.idToIndex(chat.model.reply) - 1 : -1; + chat.model.reply = idx >= 0 ? chat.model.indexToId(idx) : null; + } + } + + Shortcut { + sequence: "Alt+F" + onActivated: { + if (chat.model.reply) { + var forwardMess = forwardCompleterComponent.createObject(timelineRoot); + forwardMess.setMessageEventId(chat.model.reply); + forwardMess.open(); + chat.model.reply = null; } } } + Shortcut { + sequence: "Ctrl+E" + onActivated: { + chat.model.edit = chat.model.reply; + } + } + Connections { - function onMovementEnded() { - if (y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height) - chat.model.currentIndex = index; + function onFocusChanged() { + readTimer.running = TimelineManager.isWindowFocused; + } + + target: TimelineManager + } + + Timer { + id: readTimer + + // force current read index to update + onTriggered: { + if (chat.model) + chat.model.setCurrentIndex(chat.model.currentIndex); + + } + interval: 1000 + } + + Component { + id: sectionHeader + + Column { + topPadding: userName_.visible? 4: 0 + bottomPadding: Settings.bubbles? (isSender? 0 : 2) : 3 + spacing: 8 + visible: (previousMessageUserId !== userId || previousMessageDay !== day || isStateEvent !== previousMessageIsStateEvent) + width: parentWidth + height: ((previousMessageDay !== day) ? dateBubble.height : 0) + (isStateEvent? 0 : userName.height +8 ) + + Label { + id: dateBubble + + anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined + visible: room && previousMessageDay !== day + text: room ? room.formatDateSeparator(timestamp) : "" + color: Nheko.colors.text + height: Math.round(fontMetrics.height * 1.4) + width: contentWidth * 1.2 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + + background: Rectangle { + radius: parent.height / 2 + color: Nheko.colors.window + } + + } + + Row { + height: userName_.height + spacing: 8 + visible: !isStateEvent && (!isSender || !Settings.bubbles) + + Avatar { + id: messageUserAvatar + + width: Nheko.avatarSize * (Settings.smallAvatars? 0.5 : 1) + height: Nheko.avatarSize * (Settings.smallAvatars? 0.5 : 1) + url: !room ? "" : room.avatarUrl(userId).replace("mxc://", "image://MxcImage/") + displayName: userName + userid: userId + onClicked: room.openUserProfile(userId) + ToolTip.visible: avatarHover.hovered + ToolTip.delay: Nheko.tooltipDelay + ToolTip.text: userid + + HoverHandler { + id: avatarHover + } + + } + + Connections { + function onRoomAvatarUrlChanged() { + messageUserAvatar.url = chat.model.avatarUrl(userId).replace("mxc://", "image://MxcImage/"); + } + + function onScrollToIndex(index) { + chat.positionViewAtIndex(index, ListView.Center); + } + + target: chat.model + } + + Label { + id: userName_ + + text: TimelineManager.escapeEmoji(userName) + color: TimelineManager.userColor(userId, Nheko.colors.base) + textFormat: Text.RichText + ToolTip.visible: displayNameHover.hovered + ToolTip.delay: Nheko.tooltipDelay + ToolTip.text: userId + + TapHandler { + onSingleTapped: chat.model.openUserProfile(userId) + dragThreshold: 0 + } + + CursorShape { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + } + + HoverHandler { + id: displayNameHover + } + + } + + Label { + id: statusMsg + color: Nheko.colors.buttonText + text: Presence.userStatus(userId) + textFormat: Text.PlainText + elide: Text.ElideRight + width: chat.delegateMaxWidth - parent.spacing * 2 - userName.implicitWidth - Nheko.avatarSize + font.italic: true + + Connections { + target: Presence + + function onPresenceChanged(id) { + if (id == userId) statusMsg.text = Presence.userStatus(userId); + } + } + } + + } } - target: chat } - } + delegate: ItemDelegate { + id: wrapper - footer: Item { - anchors.horizontalCenter: parent.horizontalCenter - anchors.margins: Nheko.paddingLarge - visible: chat.model && chat.model.paginationInProgress - // hacky, but works - height: loadingSpinner.height + 2 * Nheko.paddingLarge + required property double proportionalHeight + required property int type + required property string typeString + required property int originalWidth + required property string blurhash + required property string body + required property string formattedBody + required property string eventId + required property string filename + required property string filesize + required property string url + required property string thumbnailUrl + required property bool isOnlyEmoji + required property bool isSender + required property bool isEncrypted + required property bool isEditable + required property bool isEdited + required property bool isStateEvent + required property bool previousMessageIsStateEvent + required property string replyTo + required property string userId + required property string roomTopic + required property string roomName + required property string callType + required property var reactions + required property int trustlevel + required property int encryptionError + required property var timestamp + required property int status + required property int index + required property int relatedEventCacheBuster + required property string previousMessageUserId + required property string day + required property string previousMessageDay + required property string userName + property bool scrolledToThis: eventId === chat.model.scrollTarget && (y + height > chat.y + chat.contentY && y < chat.y + chat.height + chat.contentY) - Spinner { - id: loadingSpinner + anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined + width: chat.delegateMaxWidth + height: section.active ? section.height + timelinerow.height : timelinerow.height - anchors.centerIn: parent + hoverEnabled: true + + background: Rectangle { + id: scrollHighlight + + opacity: 0 + visible: true + z: 1 + enabled: false + color: Nheko.colors.highlight + + states: State { + name: "revealed" + when: wrapper.scrolledToThis + } + + transitions: Transition { + from: "" + to: "revealed" + + SequentialAnimation { + PropertyAnimation { + target: scrollHighlight + properties: "opacity" + easing.type: Easing.InOutQuad + from: 0 + to: 1 + duration: 500 + } + + PropertyAnimation { + target: scrollHighlight + properties: "opacity" + easing.type: Easing.InOutQuad + from: 1 + to: 0 + duration: 500 + } + + ScriptAction { + script: chat.model.eventShown() + } + + } + + } + + } + + Loader { + id: section + + property int parentWidth: parent.width + property string userId: wrapper.userId + property string previousMessageUserId: wrapper.previousMessageUserId + property string day: wrapper.day + property string previousMessageDay: wrapper.previousMessageDay + property bool previousMessageIsStateEvent: wrapper.previousMessageIsStateEvent + property bool isStateEvent: wrapper.isStateEvent + property bool isSender: wrapper.isSender + property string userName: wrapper.userName + property date timestamp: wrapper.timestamp + + z: 4 + active: previousMessageUserId !== undefined && previousMessageUserId !== userId || previousMessageDay !== day || previousMessageIsStateEvent !== isStateEvent + //asynchronous: true + sourceComponent: sectionHeader + visible: status == Loader.Ready + } + + TimelineRow { + id: timelinerow + + hovered: (wrapper.hovered && !messageActionHover.hovered) || (messageActions.model != undefined && messageActions.model.eventId == timelinerow.eventId) + + proportionalHeight: wrapper.proportionalHeight + type: chat.model, wrapper.type + typeString: wrapper.typeString + originalWidth: wrapper.originalWidth + blurhash: wrapper.blurhash + body: wrapper.body + formattedBody: wrapper.formattedBody + eventId: chat.model, wrapper.eventId + filename: wrapper.filename + filesize: wrapper.filesize + url: wrapper.url + thumbnailUrl: wrapper.thumbnailUrl + isOnlyEmoji: wrapper.isOnlyEmoji + isSender: wrapper.isSender + isEncrypted: wrapper.isEncrypted + isEditable: wrapper.isEditable + isEdited: wrapper.isEdited + isStateEvent: wrapper.isStateEvent + replyTo: wrapper.replyTo + userId: wrapper.userId + userName: wrapper.userName + roomTopic: wrapper.roomTopic + roomName: wrapper.roomName + callType: wrapper.callType + reactions: wrapper.reactions + trustlevel: wrapper.trustlevel + encryptionError: wrapper.encryptionError + timestamp: wrapper.timestamp + status: wrapper.status + relatedEventCacheBuster: wrapper.relatedEventCacheBuster + y: section.visible && section.active ? section.y + section.height : 0 + } + + onHoveredChanged: { + if (!Settings.mobileMode && hovered) { + if (!messageActionHover.hovered) { + messageActions.attached = timelinerow; + messageActions.model = timelinerow; + } + } + } + + Connections { + function onMovementEnded() { + if (y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height) + chat.model.currentIndex = index; + + } + + target: chat + } + + } + + footer: Item { + anchors.horizontalCenter: parent.horizontalCenter anchors.margins: Nheko.paddingLarge - running: chat.model && chat.model.paginationInProgress - foreground: Nheko.colors.mid - z: 3 + visible: chat.model && chat.model.paginationInProgress + // hacky, but works + height: loadingSpinner.height + 2 * Nheko.paddingLarge + + Spinner { + id: loadingSpinner + + anchors.centerIn: parent + anchors.margins: Nheko.paddingLarge + running: chat.model && chat.model.paginationInProgress + foreground: Nheko.colors.mid + z: 3 + } + } } - } Platform.Menu { @@ -572,17 +592,17 @@ ScrollView { isEditable = isEditable_; isSender = isSender_; if (text_) - text = text_; + text = text_; else - text = ""; + text = ""; if (link_) - link = link_; + link = link_; else - link = ""; + link = ""; if (showAt_) - open(showAt_); + open(showAt_); else - open(); + open(); } Platform.MenuItem { @@ -732,5 +752,4 @@ ScrollView { } } - } diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 872e61f8..f3893998 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -49,6 +49,7 @@ #include "ui/MxcMediaProxy.h" #include "ui/NhekoCursorShape.h" #include "ui/NhekoDropArea.h" +#include "ui/NhekoEventObserver.h" #include "ui/NhekoGlobalObject.h" #include "ui/UIA.h" #include "voip/WebRTCSession.h" @@ -164,6 +165,7 @@ MainWindow::registerQmlTypes() qmlRegisterType("im.nheko", 1, 0, "DelegateChooser"); qmlRegisterType("im.nheko", 1, 0, "NhekoDropArea"); qmlRegisterType("im.nheko", 1, 0, "CursorShape"); + qmlRegisterType("im.nheko", 1, 0, "EventObserver"); qmlRegisterType("im.nheko", 1, 0, "MxcAnimatedImage"); qmlRegisterType("im.nheko", 1, 0, "MxcMedia"); qmlRegisterType("im.nheko", 1, 0, "RoomDirectoryModel"); diff --git a/src/ui/NhekoEventObserver.cpp b/src/ui/NhekoEventObserver.cpp new file mode 100644 index 00000000..5e67cec4 --- /dev/null +++ b/src/ui/NhekoEventObserver.cpp @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2021 Nheko Contributors +// SPDX-FileCopyrightText: 2022 Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "NhekoEventObserver.h" + +#include + +#include "Logging.h" + +NhekoEventObserver::NhekoEventObserver(QQuickItem *parent) + : QQuickItem(parent) +{ + setFiltersChildMouseEvents(true); +} + +bool +NhekoEventObserver::childMouseEventFilter(QQuickItem * /*item*/, QEvent *event) +{ + // nhlog::ui()->debug("Touched {}", item->metaObject()->className()); + + auto setTouched = [this](bool touched) { + if (touched != this->wasTouched_) { + this->wasTouched_ = touched; + emit wasTouchedChanged(); + } + }; + + // see + // https://code.qt.io/cgit/qt/qtdeclarative.git/tree/src/quicktemplates2/qquickscrollview.cpp?id=7f29e89c26ae2babc358b1c4e6f965af6ec759f4#n471 + switch (event->type()) { + case QEvent::TouchBegin: + case QEvent::TouchEnd: + setTouched(true); + break; + + case QEvent::MouseButtonPress: + if (static_cast(event)->source() == Qt::MouseEventNotSynthesized) { + setTouched(false); + } + break; + + case QEvent::MouseMove: + case QEvent::MouseButtonRelease: + if (static_cast(event)->source() == Qt::MouseEventNotSynthesized) + setTouched(false); + break; + + case QEvent::HoverEnter: + case QEvent::HoverMove: + case QEvent::Wheel: + setTouched(false); + break; + + default: + break; + } + + return false; +} diff --git a/src/ui/NhekoEventObserver.h b/src/ui/NhekoEventObserver.h new file mode 100644 index 00000000..6d14f30f --- /dev/null +++ b/src/ui/NhekoEventObserver.h @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2022 Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +class NhekoEventObserver : public QQuickItem +{ + Q_OBJECT + + Q_PROPERTY(bool wasTouched READ wasTouched NOTIFY wasTouchedChanged) + +public: + explicit NhekoEventObserver(QQuickItem *parent = 0); + + bool childMouseEventFilter(QQuickItem *item, QEvent *event) override; + +private: + bool wasTouched() { return wasTouched_; } + + bool wasTouched_ = false; + +signals: + void wasTouchedChanged(); +};