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(); +};