Yeet HoverActions into the sun

Replace HoverActions with an inline action component that appears on hover. There are only actions for reply and react if there is space the overflow button opens the normal message menu.

NOTE: the most recent update changes things slightly, from the images below the buttons are now top aligned because of potentially hige messages. The actions are also now disabled for compact mode as they never really made sense there anyway. The menu now has all options so no one is missing out.

For normal messages

![image](/uploads/b8679eb09c9190404fc84f01e14169af/image.png){width=419 height=138}

When space is limited

![image](/uploads/ecd7c725ea2526689e586a2d786f389e/image.png){width=411 height=130}

User messages

![image](/uploads/767ef09f6650a5fb6abf3a49ef9f9b90/image.png){width=296 height=114}

BUG: 503784
This commit is contained in:
James Graham
2025-05-11 11:11:52 +01:00
parent a04769baad
commit 101a8b9ec3
12 changed files with 239 additions and 222 deletions

View File

@@ -101,7 +101,7 @@ parts:
- olm
- qtkeychain
source: https://github.com/quotient-im/libQuotient.git
source-tag: 0.9.1
source-tag: 0.9.2
source-depth: 1
plugin: cmake
build-environment:

View File

@@ -37,6 +37,9 @@
#include <Quotient/events/simplestateevents.h>
#include <Quotient/jobs/downloadfilejob.h>
#include <Quotient/qt_connection_util.h>
#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1)
#include <Quotient/thread.h>
#endif
#include "chatbarcache.h"
#include "clipboard.h"
@@ -1708,4 +1711,34 @@ bool NeoChatRoom::isEventPinned(const QString &eventId) const
return pinnedEventIds().contains(eventId);
}
bool NeoChatRoom::eventIsThreaded(const QString &eventId) const
{
const auto event = eventCast<const RoomMessageEvent>(getEvent(eventId).first);
if (event == nullptr) {
return false;
}
#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1)
return event->isThreaded() || threads().contains(eventId);
#else
return event->isThreaded();
#endif
}
QString NeoChatRoom::rootIdForThread(const QString &eventId) const
{
const auto event = eventCast<const RoomMessageEvent>(getEvent(eventId).first);
if (event == nullptr) {
return {};
}
auto rootId = event->threadRootEventId();
#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1)
if (rootId.isEmpty() && threads().contains(eventId)) {
rootId = event->id();
}
#endif
return rootId;
}
#include "moc_neochatroom.cpp"

View File

@@ -576,6 +576,17 @@ public:
*/
Q_INVOKABLE bool isEventPinned(const QString &eventId) const;
/**
* @return True if the given @p eventId is threaded.
*/
Q_INVOKABLE bool eventIsThreaded(const QString &eventId) const;
/**
* @return Returns the thread root ID for @p eventId as a string. The string
* is empty if the event is not part of a thread.
*/
Q_INVOKABLE QString rootIdForThread(const QString &eventId) const;
private:
bool m_visible = false;

View File

@@ -7,7 +7,6 @@ ecm_add_qml_module(Timeline GENERATE_PLUGIN_SOURCE
OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/src/org/kde/neochat/timeline
QML_FILES
TimelineView.qml
HoverActions.qml
EventDelegate.qml
HiddenDelegate.qml
MessageDelegate.qml
@@ -20,6 +19,7 @@ ecm_add_qml_module(Timeline GENERATE_PLUGIN_SOURCE
Bubble.qml
AvatarFlow.qml
SectionDelegate.qml
QuickActions.qml
BaseMessageComponentChooser.qml
MessageComponentChooser.qml
ReplyMessageComponentChooser.qml

View File

@@ -104,6 +104,19 @@ KirigamiComponents.ConvergentContextMenu {
}
}
component ReplyThreadMessageAction: QQC2.Action {
text: i18nc("@action:button", "Reply in Thread")
icon.name: "dialog-messages"
onTriggered: {
currentRoom.threadCache.replyId = "";
currentRoom.threadCache.threadId = currentRoom.eventIsThreaded(root.eventId) ? currentRoom.rootIdForThread(root.eventId) : root.eventId;
currentRoom.mainCache.clearRelations();
currentRoom.editCache.clearRelations();
RoomManager.requestFullScreenClose();
}
}
component ReportMessageAction: Kirigami.Action {
text: i18nc("@action:button 'Report' as in 'Report this event to the administrators'", "Report")
icon.name: "dialog-warning-symbolic"

View File

@@ -1,181 +0,0 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat
import org.kde.neochat.chatbar
/**
* @brief A component that provides a set of actions when a message is hovered in the timeline.
*
* There is also an icon to show that a message has come from a verified device in
* encrypted chats.
*/
QQC2.Control {
id: root
/**
* @brief The current message delegate the actions are being shown on.
*/
property var delegate: null
/**
* @brief The current room that user is viewing.
*/
required property NeoChatRoom currentRoom
/**
* @brief Whether the actions should be shown.
*/
readonly property bool showActions: delegate && delegate.hovered
/**
* @brief Request that the chat bar be focussed.
*/
signal focusChatBar
topPadding: 0
bottomPadding: 0
leftPadding: 0
rightPadding: 0
visible: (root.hovered || root.showActions || showActionsTimer.running) && !Kirigami.Settings.isMobile && (!root.delegate.isThreaded || !NeoChatConfig.threads)
onVisibleChanged: {
if (visible) {
// HACK: delay disapearing by 200ms, otherwise this can create some glitches
// See https://invent.kde.org/network/neochat/-/issues/333
showActionsTimer.restart();
}
}
Timer {
id: showActionsTimer
interval: 200
}
function updatePosition(): void {
if (delegate) {
root.x = delegate.contentItem.x + delegate.bubbleWidth - root.implicitWidth - Kirigami.Units.largeSpacing;
root.y = delegate.mapToItem(parent, 0, 0).y + delegate.bubbleY - height + Kirigami.Units.smallSpacing;
}
}
onDelegateChanged: updatePosition()
onWidthChanged: updatePosition()
contentItem: RowLayout {
id: actionsLayout
spacing: Kirigami.Units.smallSpacing
Item {
Layout.fillWidth: true
}
Kirigami.Icon {
source: "security-high"
width: height
height: root.height
visible: root.delegate && root.delegate.verified
HoverHandler {
id: hover
}
QQC2.ToolTip.text: i18n("This message was sent from a verified device")
QQC2.ToolTip.visible: hover.hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.Button {
text: i18n("React")
icon.name: "preferences-desktop-emoticons"
onClicked: emojiDialog.open()
display: QQC2.ToolButton.IconOnly
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.Button {
visible: root.delegate && root.delegate.isEditable && !root.currentRoom.readOnly
text: i18n("Edit")
icon.name: "document-edit"
display: QQC2.Button.IconOnly
onClicked: {
root.currentRoom.editCache.editId = root.delegate.eventId;
root.currentRoom.mainCache.replyId = "";
root.currentRoom.mainCache.threadId = "";
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.Button {
visible: !root.currentRoom.readOnly
text: i18n("Reply")
icon.name: "mail-replied-symbolic"
display: QQC2.Button.IconOnly
onClicked: {
root.currentRoom.mainCache.replyId = root.delegate.eventId;
root.currentRoom.editCache.editId = "";
root.currentRoom.mainCache.threadId = "";
root.focusChatBar();
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.Button {
visible: NeoChatConfig.threads && !root.currentRoom.readOnly && !root.delegate?.isPoll
text: i18n("Reply in Thread")
icon.name: "dialog-messages"
display: QQC2.Button.IconOnly
onClicked: {
root.currentRoom.threadCache.replyId = "";
root.currentRoom.threadCache.threadId = root.delegate.isThreaded ? root.delegate.threadRoot : root.delegate.eventId;
root.currentRoom.mainCache.clearRelations();
root.currentRoom.editCache.clearRelations();
root.focusChatBar();
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.Button {
visible: (root.delegate?.isPoll ?? false) && !ContentProvider.handlerForPoll(root.currentRoom, root.delegate.eventId).hasEnded
text: i18n("End Poll")
icon.name: "gtk-stop"
display: QQC2.ToolButton.IconOnly
onClicked: root.currentRoom.poll(root.delegate.eventId).endPoll()
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
EmojiDialog {
id: emojiDialog
currentRoom: root.currentRoom
showQuickReaction: true
showStickers: false
onChosen: emoji => {
root.currentRoom.toggleReaction(root.delegate.eventId, emoji);
if (!Kirigami.Settings.isMobile) {
root.focusChatBar();
}
}
}
}
}

View File

@@ -89,20 +89,6 @@ MessageDelegateBase {
*/
required property bool verified
/**
* @brief The y position of the message bubble.
*
* @note Used for positioning the hover actions.
*/
readonly property alias bubbleY: bubble.y
/**
* @brief The width of the message bubble.
*
* @note Used for sizing the hover actions.
*/
readonly property alias bubbleWidth: bubble.width
/**
* @brief Open the any message media externally.
*/
@@ -202,17 +188,9 @@ MessageDelegateBase {
radius: Kirigami.Units.cornerRadius
}
// show hover actions
onHoveredChanged: {
if (hovered && !Kirigami.Settings.isMobile) {
root.setHoverActionsToDelegate();
}
}
function setHoverActionsToDelegate() {
if (ListView.view.setHoverActionsToDelegate) {
ListView.view.setHoverActionsToDelegate(root);
}
quickActionComponent: QuickActions {
room: root.room
eventId: root.eventId
}
QtObject {

View File

@@ -50,6 +50,8 @@ DelegateContextMenu {
DelegateContextMenu.ReplyMessageAction {}
DelegateContextMenu.ReplyThreadMessageAction {}
QQC2.Action {
text: i18nc("@action:inmenu As in 'Forward this message'", "Forward…")
icon.name: "mail-forward-symbolic"

View File

@@ -0,0 +1,90 @@
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat
RowLayout {
id: root
/**
* @brief The NeoChatRoom the delegate is being displayed in.
*/
required property NeoChatRoom room
/**
* @brief The matrix ID of the message event.
*/
required property string eventId
property real availableWidth: 0.0
property bool reacting: false
QQC2.Button {
id: reactButton
visible: root.availableWidth > overflowButton.implicitWidth + root.spacing + reactButton.implicitWidth
text: i18n("React")
icon.name: "preferences-desktop-emoticons"
display: QQC2.ToolButton.IconOnly
onClicked: {
var dialog = emojiDialog.createObject(reactButton);
dialog.chosen.connect(emoji => {
root.reacting = false;
root.room.toggleReaction(root.eventId, emoji);
if (!Kirigami.Settings.isMobile) {
// root.focusChatBar();
}
});
dialog.closed.connect(() => {
root.reacting = false;
})
root.reacting = true;
dialog.open();
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
Component {
id: emojiDialog
EmojiDialog {
currentRoom: root.room
showQuickReaction: true
}
}
}
QQC2.Button {
id: replyButton
visible: !root.room.readOnly && root.availableWidth > overflowButton.implicitWidth + reactButton.implicitWidth + replyButton.implicitWidth + root.spacing
text: i18n("Reply")
icon.name: "mail-replied-symbolic"
display: QQC2.Button.IconOnly
onClicked: {
root.room.mainCache.replyId = root.eventId;
root.room.editCache.editId = "";
root.room.mainCache.threadId = "";
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.Button {
id: overflowButton
text: i18n("Message menu")
icon.name: "overflow-menu"
onClicked: _private.showMessageMenu()
display: QQC2.ToolButton.IconOnly
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
}

View File

@@ -288,14 +288,6 @@ QQC2.ScrollView {
itemAtIndex(index).isTemporaryHighlighted = true;
}
HoverActions {
id: hoverActions
currentRoom: root.currentRoom
onFocusChatBar: root.focusChatBar()
}
onContentYChanged: hoverActions.updatePosition()
Connections {
target: root.timelineModel
@@ -360,10 +352,6 @@ QQC2.ScrollView {
}
return false;
}
function setHoverActionsToDelegate(delegate) {
hoverActions.delegate = delegate;
}
}
function goToLastMessage() {

View File

@@ -120,7 +120,7 @@ void MessageDelegateBase::setPercentageValues(bool fillWidth)
m_contentSizeHelper.setStartPercentWidth(100);
m_contentSizeHelper.setEndPercentWidth(100);
} else {
m_contentSizeHelper.setStartPercentWidth(90);
m_contentSizeHelper.setStartPercentWidth(85);
m_contentSizeHelper.setEndPercentWidth(60);
}
}
@@ -402,6 +402,7 @@ void MessageDelegateBase::setCompactMode(bool compactMode)
Q_EMIT maxContentWidthChanged();
updateBackground();
updateQuickAction();
}
void MessageDelegateBase::updateBackground()
@@ -435,6 +436,54 @@ void MessageDelegateBase::updateBackground()
}
}
QQmlComponent *MessageDelegateBase::quickActionComponent() const
{
return m_quickActionComponent;
}
void MessageDelegateBase::setQuickActionComponent(QQmlComponent *quickActionComponent)
{
if (quickActionComponent == m_quickActionComponent) {
return;
}
m_quickActionComponent = quickActionComponent;
Q_EMIT quickActionComponentChanged();
updateQuickAction();
}
void MessageDelegateBase::updateQuickAction()
{
if (m_quickActionComponent && !m_compactMode && m_hovered && !m_quickActionItem && !m_quickActionIncubating) {
const auto quickActionIncubator = new MessageObjectIncubator(
m_objectInitialCallback,
[this](MessageObjectIncubator *incubator) {
if (!incubator) {
return;
}
const auto quickActionObject = qobject_cast<QQuickItem *>(incubator->object());
if (quickActionObject) {
if (!m_compactMode) {
m_quickActionItem = quickActionObject;
connect(m_quickActionItem, SIGNAL(reactingChanged()), this, SLOT(updateQuickAction()));
} else {
cleanupItem(quickActionObject);
}
markAsDirty();
}
cleanupIncubator(incubator);
m_quickActionIncubating = false;
},
m_errorCallback);
m_quickActionComponent->create(*quickActionIncubator, qmlContext(m_quickActionComponent));
m_quickActionIncubating = true;
} else if (m_quickActionItem && !m_hovered && !m_quickActionItem->property("reacting").toBool()) {
cleanupItem(m_quickActionItem);
markAsDirty();
}
}
bool MessageDelegateBase::showLocalMessagesOnRight() const
{
return m_showLocalMessagesOnRight;
@@ -462,6 +511,7 @@ void MessageDelegateBase::updateImplicitHeight()
}
qreal avatarHeight = 0.0;
qreal contentHeight = 0.0;
qreal quickActionHeight = 0.0;
if (showAvatar() && m_avatarItem) {
m_avatarItem->setImplicitWidth(m_avatarSize);
m_avatarItem->setImplicitHeight(m_avatarSize);
@@ -470,7 +520,10 @@ void MessageDelegateBase::updateImplicitHeight()
if (m_contentItem) {
contentHeight = m_contentItem->implicitHeight();
}
implicitHeight += std::max(avatarHeight, contentHeight);
if (m_quickActionItem) {
quickActionHeight = m_quickActionItem->implicitHeight();
}
implicitHeight += std::max({avatarHeight, contentHeight, quickActionHeight});
if (avatarHeight > 0 || contentHeight > 0) {
numObj++;
}
@@ -528,6 +581,17 @@ void MessageDelegateBase::resizeContent()
m_contentItem->setSize(QSizeF(contentItemWidth, m_contentItem->implicitHeight()));
yAdd = std::max(yAdd, m_contentItem->implicitHeight());
}
if (m_quickActionItem) {
const auto availableWidth = m_contentItem && showMessageOnRight() ? m_contentItem->x() - m_contentSizeHelper.leftPadding()
: m_sizeHelper.rightX() - m_contentItem->x() - m_contentItem->width() - m_spacing;
m_quickActionItem->setProperty("availableWidth", availableWidth);
const auto actionX = showMessageOnRight() && m_contentItem ? m_contentItem->x() - m_quickActionItem->implicitWidth() - m_spacing
: m_contentItem->x() + m_contentItem->width() + m_spacing;
const auto actionWidth = std::min(m_quickActionItem->implicitWidth(), availableWidth);
m_quickActionItem->setPosition(QPointF(actionX, nextY));
m_quickActionItem->setSize(QSizeF(actionWidth, m_quickActionItem->implicitHeight()));
yAdd = std::max(yAdd, m_quickActionItem->implicitHeight());
}
nextY += yAdd + m_spacing;
if (m_showReadMarkers && m_readMarkerItem) {
qreal extraSpacing = m_readMarkerItem->implicitWidth() < m_sizeHelper.availableWidth() - m_spacing ? m_spacing : 0;
@@ -545,6 +609,7 @@ void MessageDelegateBase::hoverEnterEvent(QHoverEvent *event)
Q_EMIT hoveredChanged();
event->setAccepted(true);
updateBackground();
updateQuickAction();
}
void MessageDelegateBase::hoverMoveEvent(QHoverEvent *event)
@@ -556,6 +621,7 @@ void MessageDelegateBase::hoverMoveEvent(QHoverEvent *event)
}
event->setAccepted(true);
updateBackground();
updateQuickAction();
}
void MessageDelegateBase::hoverLeaveEvent(QHoverEvent *event)
@@ -564,6 +630,7 @@ void MessageDelegateBase::hoverLeaveEvent(QHoverEvent *event)
Q_EMIT hoveredChanged();
event->setAccepted(true);
updateBackground();
updateQuickAction();
}
bool MessageDelegateBase::isTemporaryHighlighted() const

View File

@@ -96,6 +96,11 @@ class MessageDelegateBase : public TimelineDelegate
Q_PROPERTY(QQmlComponent *compactBackgroundComponent READ compactBackgroundComponent WRITE setCompactBackgroundComponentt NOTIFY
compactBackgroundComponentChanged FINAL)
/**
* @brief The component to use to visualize quick actions.
*/
Q_PROPERTY(QQmlComponent *quickActionComponent READ quickActionComponent WRITE setQuickActionComponent NOTIFY quickActionComponentChanged FINAL)
/**
* @brief Whether to use the compact mode appearance.
*/
@@ -152,6 +157,9 @@ public:
bool compactMode() const;
void setCompactMode(bool compactMode);
QQmlComponent *quickActionComponent() const;
void setQuickActionComponent(QQmlComponent *quickActionComponent);
bool showLocalMessagesOnRight() const;
void setShowLocalMessagesOnRight(bool showLocalMessagesOnRight);
@@ -172,6 +180,7 @@ Q_SIGNALS:
void readMarkerComponentChanged();
void showReadMarkersChanged();
void compactBackgroundComponentChanged();
void quickActionComponentChanged();
void compactModeChanged();
void showLocalMessagesOnRightChanged();
void isTemporaryHighlightedChanged();
@@ -211,6 +220,10 @@ private:
bool m_compactMode = false;
void updateBackground();
QPointer<QQmlComponent> m_quickActionComponent;
bool m_quickActionIncubating = false;
QPointer<QQuickItem> m_quickActionItem;
bool m_showLocalMessagesOnRight = true;
bool m_hovered = false;
@@ -245,4 +258,7 @@ private:
void resizeContent() override;
QPointer<QTimer> m_temporaryHighlightTimer;
private Q_SLOTS:
void updateQuickAction();
};