diff --git a/snapcraft.yaml b/snapcraft.yaml index b91239726..ef64694a8 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -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: diff --git a/src/libneochat/neochatroom.cpp b/src/libneochat/neochatroom.cpp index 43d47be42..b86ea65ef 100644 --- a/src/libneochat/neochatroom.cpp +++ b/src/libneochat/neochatroom.cpp @@ -37,6 +37,9 @@ #include #include #include +#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1) +#include +#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(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(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" diff --git a/src/libneochat/neochatroom.h b/src/libneochat/neochatroom.h index 3c70d5197..c57b8922f 100644 --- a/src/libneochat/neochatroom.h +++ b/src/libneochat/neochatroom.h @@ -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; diff --git a/src/timeline/CMakeLists.txt b/src/timeline/CMakeLists.txt index dea1dc278..b3d43f7a0 100644 --- a/src/timeline/CMakeLists.txt +++ b/src/timeline/CMakeLists.txt @@ -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 diff --git a/src/timeline/DelegateContextMenu.qml b/src/timeline/DelegateContextMenu.qml index 233ff03c5..88c54700c 100644 --- a/src/timeline/DelegateContextMenu.qml +++ b/src/timeline/DelegateContextMenu.qml @@ -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" diff --git a/src/timeline/HoverActions.qml b/src/timeline/HoverActions.qml deleted file mode 100644 index 45f0b3243..000000000 --- a/src/timeline/HoverActions.qml +++ /dev/null @@ -1,181 +0,0 @@ -// SPDX-FileCopyrightText: 2023 James Graham -// 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(); - } - } - } - } -} diff --git a/src/timeline/MessageDelegate.qml b/src/timeline/MessageDelegate.qml index 6becbca15..12a7d03de 100644 --- a/src/timeline/MessageDelegate.qml +++ b/src/timeline/MessageDelegate.qml @@ -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 { diff --git a/src/timeline/MessageDelegateContextMenu.qml b/src/timeline/MessageDelegateContextMenu.qml index a2b13cff6..17a096675 100644 --- a/src/timeline/MessageDelegateContextMenu.qml +++ b/src/timeline/MessageDelegateContextMenu.qml @@ -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" diff --git a/src/timeline/QuickActions.qml b/src/timeline/QuickActions.qml new file mode 100644 index 000000000..c7334d016 --- /dev/null +++ b/src/timeline/QuickActions.qml @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2025 James Graham +// 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 + } +} diff --git a/src/timeline/TimelineView.qml b/src/timeline/TimelineView.qml index 706f80d04..a818e00b5 100644 --- a/src/timeline/TimelineView.qml +++ b/src/timeline/TimelineView.qml @@ -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() { diff --git a/src/timeline/messagedelegate.cpp b/src/timeline/messagedelegate.cpp index 35d4293e1..c3cc70648 100644 --- a/src/timeline/messagedelegate.cpp +++ b/src/timeline/messagedelegate.cpp @@ -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(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 diff --git a/src/timeline/messagedelegate.h b/src/timeline/messagedelegate.h index 50ef2d53c..9fd9b30bc 100644 --- a/src/timeline/messagedelegate.h +++ b/src/timeline/messagedelegate.h @@ -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 m_quickActionComponent; + bool m_quickActionIncubating = false; + QPointer m_quickActionItem; + bool m_showLocalMessagesOnRight = true; bool m_hovered = false; @@ -245,4 +258,7 @@ private: void resizeContent() override; QPointer m_temporaryHighlightTimer; + +private Q_SLOTS: + void updateQuickAction(); };