diff --git a/src/app/qml/RoomPage.qml b/src/app/qml/RoomPage.qml index 056065be7..e28572b18 100644 --- a/src/app/qml/RoomPage.qml +++ b/src/app/qml/RoomPage.qml @@ -229,6 +229,58 @@ Kirigami.Page { Layout.fillWidth: true } + Kirigami.InlineMessage { + id: selectedMessagesControl + + Layout.fillWidth: true + + showCloseButton: false + visible: root.currentRoom?.selectedMessageCount > 0 + position: Kirigami.InlineMessage.Position.Header + type: Kirigami.MessageType.Positive + icon.name: "edit-select-all-symbolic" + + text: i18nc("@info", "Selected Messages: %1", root.currentRoom?.selectedMessageCount) + + actions: [ + Kirigami.Action { + text: i18nc("@action:button", "Copy Conversation") + icon.name: "edit-copy" + onTriggered: { + Clipboard.saveText(root.currentRoom.getFormattedSelectedMessages()) + showPassiveNotification(i18nc("@info", "Conversation copied to clipboard")); + } + }, + Kirigami.Action { + text: i18nc("@action:button", "Delete Messages") + icon.name: "trash-empty-symbolic" + icon.color: Kirigami.Theme.negativeTextColor + enabled: root.currentRoom?.canDeleteSelectedMessages + onTriggered: { + let dialog = pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ReasonDialog'), { + title: i18nc("@title:dialog", "Remove Messages"), + placeholder: i18nc("@info:placeholder", "Optionally give a reason for removing these messages"), + actionText: i18nc("@action:button 'Remove' as in 'Remove these messages'", "Remove"), + icon: "delete", + reporting: false, + connection: root.currentRoom.connection, + }, { + title: i18nc("@title:dialog", "Remove Messages"), + width: Kirigami.Units.gridUnit * 25 + }) as ReasonDialog; + dialog.accepted.connect(reason => { + root.currentRoom.deleteSelectedMessages(reason); + }); + } + }, + Kirigami.Action { + icon.name: "dialog-close" + icon.color: Kirigami.Theme.negativeTextColor + onTriggered: root.currentRoom.clearSelectedMessages() + } + ] + } + Kirigami.InlineMessage { id: banner diff --git a/src/app/roommanager.cpp b/src/app/roommanager.cpp index 6e5813717..5166ba1d7 100644 --- a/src/app/roommanager.cpp +++ b/src/app/roommanager.cpp @@ -604,6 +604,7 @@ QString RoomManager::findSpaceIdForCurrentRoom() const void RoomManager::setCurrentRoom(const QString &roomId) { if (m_currentRoom != nullptr) { + m_currentRoom->clearSelectedMessages(); m_currentRoom->disconnect(this); } diff --git a/src/libneochat/neochatroom.cpp b/src/libneochat/neochatroom.cpp index fe462a815..91f16c3c9 100644 --- a/src/libneochat/neochatroom.cpp +++ b/src/libneochat/neochatroom.cpp @@ -59,6 +59,8 @@ #include #include +#include + using namespace Quotient; std::function NeoChatRoom::m_hiddenFilter = [](const Quotient::RoomEvent *) -> bool { @@ -630,7 +632,14 @@ bool NeoChatRoom::isUserBanned(const QString &user) const void NeoChatRoom::deleteMessagesByUser(const QString &user, const QString &reason) { - doDeleteMessagesByUser(user, reason); + QStringList events; + for (const auto &event : messageEvents()) { + if (event->senderId() == user && !event->isRedacted() && !event.viewAs() && !event->isStateEvent()) { + events += event->id(); + } + } + + doDeleteMessageIds(events, reason); } QString NeoChatRoom::historyVisibility() const @@ -761,16 +770,10 @@ void NeoChatRoom::setUserPowerLevel(const QString &userID, const int &powerLevel } } -QCoro::Task NeoChatRoom::doDeleteMessagesByUser(const QString &user, QString reason) +QCoro::Task NeoChatRoom::doDeleteMessageIds(const QStringList eventIds, QString reason) { - QStringList events; - for (const auto &event : messageEvents()) { - if (event->senderId() == user && !event->isRedacted() && !event.viewAs() && !event->isStateEvent()) { - events += event->id(); - } - } - for (const auto &e : events) { - auto job = connection()->callApi(id(), QString::fromLatin1(QUrl::toPercentEncoding(e)), connection()->generateTxnId(), reason); + for (const auto &eventId : eventIds) { + auto job = connection()->callApi(id(), eventId, connection()->generateTxnId(), reason); co_await qCoro(job.get(), &BaseJob::finished); if (job->error() != BaseJob::Success) { qWarning() << "Error: \"" << job->error() << "\" while deleting messages. Aborting"; @@ -1963,4 +1966,96 @@ QList NeoChatRoom::sortedMemberIds() const return m_sortedMemberIds; } +int NeoChatRoom::selectedMessageCount() const +{ + return m_selectedMessageIds.size(); +} + +bool NeoChatRoom::canDeleteSelectedMessages() const +{ + if (canSendState("redact"_L1)) { + return true; + } + + const QString localUserId = connection()->userId(); + return std::ranges::all_of(m_selectedMessageIds, [this, localUserId](const QString &eventId) { + const auto eventIt = findInTimeline(eventId); + if (eventIt == historyEdge()) { + return false; + } + + const RoomEvent *event = eventIt->event(); + return event && (event->senderId() == localUserId); + }); +} + +bool NeoChatRoom::isMessageSelected(const QString &eventId) const +{ + return m_selectedMessageIds.contains(eventId); +} + +void NeoChatRoom::toggleMessageSelection(const QString &eventId) +{ + if (!m_selectedMessageIds.remove(eventId)) { + m_selectedMessageIds.insert(eventId); + } + + Q_EMIT selectionChanged(); +} + +QString NeoChatRoom::getFormattedSelectedMessages() const +{ + QVector events; + events.reserve(m_selectedMessageIds.size()); + + std::ranges::copy(m_selectedMessageIds | std::views::transform([this](const QString &eventId) -> const RoomEvent * { + const auto eventIt = findInTimeline(eventId); + return eventIt != historyEdge() ? eventIt->event() : nullptr; + }) | std::views::filter([](const RoomEvent *event) { + return event != nullptr; + }), + std::back_inserter(events)); + + std::ranges::sort(events, {}, &RoomEvent::originTimestamp); + + QString formattedContent; + formattedContent.reserve(events.size() * 256); // estimate an average of 256 characters per message + + for (const RoomEvent *event : events) { + formattedContent += EventHandler::authorDisplayName(this, event); + formattedContent += u" — "_s; + formattedContent += EventHandler::dateTime(this, event).shortDateTime(); + formattedContent += u'\n'; + formattedContent += EventHandler::plainBody(this, event); + formattedContent += u"\n\n"_s; + } + + return formattedContent.trimmed(); +} + +void NeoChatRoom::deleteSelectedMessages(const QString &reason) +{ + QStringList events; + for (const auto &eventId : m_selectedMessageIds) { + const auto eventIt = findInTimeline(eventId); + if (eventIt == historyEdge()) { + continue; + } + + const RoomEvent *event = eventIt->event(); + if (event && !event->isRedacted() && !is(*event)) { + events += eventId; + } + } + + doDeleteMessageIds(events, reason); + clearSelectedMessages(); +} + +void NeoChatRoom::clearSelectedMessages() +{ + m_selectedMessageIds.clear(); + Q_EMIT selectionChanged(); +} + #include "moc_neochatroom.cpp" diff --git a/src/libneochat/neochatroom.h b/src/libneochat/neochatroom.h index d1654fe12..e03afcdf1 100644 --- a/src/libneochat/neochatroom.h +++ b/src/libneochat/neochatroom.h @@ -220,6 +220,16 @@ class NeoChatRoom : public Quotient::Room */ Q_PROPERTY(bool spaceHasUnreadMessages READ spaceHasUnreadMessages NOTIFY spaceHasUnreadMessagesChanged) + /** + * @brief The number of selected messages in the room. + */ + Q_PROPERTY(int selectedMessageCount READ selectedMessageCount NOTIFY selectionChanged) + + /** + * @brief Whether the user can delete the selected messages. + */ + Q_PROPERTY(bool canDeleteSelectedMessages READ canDeleteSelectedMessages NOTIFY selectionChanged) + public: explicit NeoChatRoom(Quotient::Connection *connection, QString roomId, Quotient::JoinState joinState = {}); @@ -676,6 +686,41 @@ public: */ QList sortedMemberIds() const; + /** + * @brief The number of selected messages in the room. + */ + int selectedMessageCount() const; + + /** + * @brief Whether the user can delete the selected messages. + */ + bool canDeleteSelectedMessages() const; + + /** + * @brief Whether the given message is selected. + */ + Q_INVOKABLE bool isMessageSelected(const QString &eventId) const; + + /** + * @brief Toggle the selection state of the given message. + */ + Q_INVOKABLE void toggleMessageSelection(const QString &eventId); + + /** + * @brief Get the content of the selected messages formatted as a single string. + */ + Q_INVOKABLE QString getFormattedSelectedMessages() const; + + /** + * @brief Delete the selected messages with an optional reason. + */ + Q_INVOKABLE void deleteSelectedMessages(const QString &reason = QString()); + + /** + * @brief Clear the selection of messages. + */ + Q_INVOKABLE void clearSelectedMessages(); + private: bool m_visible = false; @@ -693,7 +738,7 @@ private: void onAddHistoricalTimelineEvents(rev_iter_t from) override; void onRedaction(const Quotient::RoomEvent &prevEvent, const Quotient::RoomEvent &after) override; - QCoro::Task doDeleteMessagesByUser(const QString &user, QString reason); + QCoro::Task doDeleteMessageIds(const QStringList eventIds, QString reason); QCoro::Task doUploadFile(QUrl url, QString body = QString(), std::optional relatesTo = std::nullopt); std::unique_ptr m_cachedEvent; @@ -713,6 +758,7 @@ private: QString m_lastUnreadHighlightId; QList m_sortedMemberIds; + QSet m_selectedMessageIds; private Q_SLOTS: void updatePushNotificationState(QString type); @@ -752,6 +798,7 @@ Q_SIGNALS: void pinnedMessageChanged(); void highlightCycleStartedChanged(); void spaceHasUnreadMessagesChanged(); + void selectionChanged(); /** * @brief Request a message be shown to the user of the given type. diff --git a/src/timeline/DelegateContextMenu.qml b/src/timeline/DelegateContextMenu.qml index 87ee33a51..66b51fd1a 100644 --- a/src/timeline/DelegateContextMenu.qml +++ b/src/timeline/DelegateContextMenu.qml @@ -407,6 +407,13 @@ KirigamiComponents.ConvergentContextMenu { onTriggered: pinned ? root.room.unpinEvent(root.eventId) : root.room.pinEvent(root.eventId) } + Kirigami.Action { + visible: root.messageComponentType !== MessageComponentType.Other + text: root.room.selectedMessageCount > 0 && root.room.isMessageSelected(root.eventId) ? i18nc("@action:inmenu", "Deselect Message") : i18nc("@action:inmenu", "Select Message") + icon.name: "edit-select-all-symbolic" + onTriggered: root.room.toggleMessageSelection(root.eventId) + } + Kirigami.Action { separator: true visible: viewSourceAction.visible diff --git a/src/timeline/EventDelegate.qml b/src/timeline/EventDelegate.qml index bf9991e68..27b9aebeb 100644 --- a/src/timeline/EventDelegate.qml +++ b/src/timeline/EventDelegate.qml @@ -2,6 +2,8 @@ // SPDX-FileCopyrightText: 2024 James Graham // SPDX-License-Identifier: GPL-3.0-only +pragma ComponentBehavior: Bound + import QtQuick import Qt.labs.qmlmodels @@ -16,6 +18,11 @@ DelegateChooser { */ required property NeoChatRoom room + /** + * @brief Whether to show selection controls for message delegate. + */ + property bool showSelectionControl: false + role: "delegateType" DelegateChoice { @@ -25,7 +32,9 @@ DelegateChooser { DelegateChoice { roleValue: DelegateType.Message - delegate: MessageDelegate {} + delegate: MessageDelegate { + showSelectionControl: root.showSelectionControl + } } DelegateChoice { diff --git a/src/timeline/MessageDelegate.qml b/src/timeline/MessageDelegate.qml index f41e2cd72..ec28c4d13 100644 --- a/src/timeline/MessageDelegate.qml +++ b/src/timeline/MessageDelegate.qml @@ -5,6 +5,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Controls as QQC2 +import QtQuick.Layouts import org.kde.kirigami as Kirigami import org.kde.kirigamiaddons.components as KirigamiComponents @@ -109,6 +110,16 @@ MessageDelegateBase { */ property bool showHighlight: root.isHighlighted || isTemporaryHighlighted + /** + * @brief Whether the message is selected. + */ + property bool selected: root.room.selectedMessageCount > 0 && room.isMessageSelected(eventId) + + /** + * @brief Whether to show selection controls for this message. + */ + property bool showSelectionControl: false + Message.room: root.room Message.timeline: root.ListView.view Message.contentModel: root.contentModel @@ -120,6 +131,7 @@ MessageDelegateBase { enableAvatars: NeoChatConfig?.showAvatarInTimeline ?? false compactMode: NeoChatConfig?.compactLayout ?? false showLocalMessagesOnRight: NeoChatConfig.showLocalMessagesOnRight + showSelection: root.showSelectionControl && room.selectedMessageCount > 0 contentItem: Bubble { id: bubble @@ -230,6 +242,20 @@ MessageDelegateBase { author: root.author } + selectionComponent: RowLayout { + spacing: Kirigami.Units.smallSpacing + implicitHeight: Kirigami.Units.gridUnit + Kirigami.Units.largeSpacing * 2 + + QQC2.CheckBox { + checked: root.selected + onClicked: root.room.toggleMessageSelection(root.eventId) + } + + Kirigami.Separator { + Layout.fillHeight: true + } + } + QtObject { id: _private diff --git a/src/timeline/TimelineView.qml b/src/timeline/TimelineView.qml index b577e5376..43d64c814 100644 --- a/src/timeline/TimelineView.qml +++ b/src/timeline/TimelineView.qml @@ -213,6 +213,7 @@ QQC2.ScrollView { model: root.messageFilterModel delegate: EventDelegate { room: _private.room + showSelectionControl: true } ColumnLayout { diff --git a/src/timeline/messagedelegate.cpp b/src/timeline/messagedelegate.cpp index a1c4c5f69..5f7b9b029 100644 --- a/src/timeline/messagedelegate.cpp +++ b/src/timeline/messagedelegate.cpp @@ -138,7 +138,10 @@ void MessageDelegateBase::setPercentageValues(bool fillWidth) void MessageDelegateBase::setContentPadding() { - m_contentSizeHelper.setLeftPadding(m_sizeHelper.leftX() + (leaveAvatarSpace() ? m_avatarSize + m_spacing : 0)); + qreal selectionOffset = (m_showSelection && m_selectionItem) ? m_selectionItem->implicitWidth() + (m_spacing * 2) : 0; + qreal avatarOffset = (leaveAvatarSpace() ? m_avatarSize + m_spacing : 0); + + m_contentSizeHelper.setLeftPadding(m_sizeHelper.leftX() + selectionOffset + avatarOffset); m_contentSizeHelper.setRightPadding(m_sizeHelper.rightPadding()); } @@ -539,6 +542,77 @@ void MessageDelegateBase::updateQuickAction() m_quickActionComponent->create(*quickActionIncubator, qmlContext(m_quickActionComponent)); } +QQmlComponent *MessageDelegateBase::selectionComponent() const +{ + return m_selectionComponent; +} + +void MessageDelegateBase::setSelectionComponent(QQmlComponent *selectionComponent) +{ + if (selectionComponent == m_selectionComponent) { + return; + } + m_selectionComponent = selectionComponent; + Q_EMIT selectionComponentChanged(); + + updateSelection(); +} + +bool MessageDelegateBase::showSelection() const +{ + return m_showSelection; +} + +void MessageDelegateBase::setShowSelection(bool showSelection) +{ + if (showSelection == m_showSelection) { + return; + } + m_showSelection = showSelection; + Q_EMIT showSelectionChanged(); + + updateSelection(); +} + +void MessageDelegateBase::updateSelection() +{ + if (m_selectionComponent && showSelection() && !m_selectionItem && !m_selectionIncubating) { + const auto selectionIncubator = new MessageObjectIncubator( + m_objectInitialCallback, + [this](MessageObjectIncubator *incubator) { + if (!incubator) { + return; + } + const auto selectionObject = qobject_cast(incubator->object()); + if (selectionObject) { + // The setting may have changed during the incubation period. + if (showSelection()) { + m_selectionItem = selectionObject; + } else { + cleanupItem(selectionObject); + } + setContentPadding(); + markAsDirty(); + } + m_selectionIncubating = false; + // We can't cleanup the incubator in the completedCallback otherwise + // we use after free when we return to the status changed function + // of that incubator + QTimer::singleShot(0, this, [this, incubator]() { + cleanupIncubator(incubator); + }); + }, + m_errorCallback); + m_activeIncubators.push_back(selectionIncubator); + m_selectionComponent->create(*selectionIncubator, qmlContext(m_selectionComponent)); + m_selectionIncubating = true; + } else if (!showSelection() && m_selectionItem) { + cleanupItem(m_selectionItem); + setContentPadding(); + markAsDirty(); + } +} + bool MessageDelegateBase::showLocalMessagesOnRight() const { return m_showLocalMessagesOnRight; @@ -623,10 +697,17 @@ void MessageDelegateBase::resizeContent() nextY += m_sectionItem->implicitHeight() + m_spacing; } qreal yAdd = 0.0; + if (m_showSelection && m_selectionItem) { + m_selectionItem->setPosition(QPointF(m_sizeHelper.leftX(), nextY)); + m_selectionItem->setSize(QSizeF(m_selectionItem->implicitWidth(), m_selectionItem->implicitHeight())); + yAdd = m_selectionItem->implicitHeight(); + } if (showAvatar() && m_avatarItem) { - m_avatarItem->setPosition(QPointF(m_sizeHelper.leftX(), nextY)); + m_avatarItem->setPosition( + QPointF(m_showSelection && m_selectionItem ? m_sizeHelper.leftX() + m_selectionItem->implicitWidth() + (m_spacing * 2) : m_sizeHelper.leftX(), + nextY)); m_avatarItem->setSize(QSizeF(m_avatarItem->implicitWidth(), m_avatarItem->implicitHeight())); - yAdd = m_avatarItem->implicitWidth(); + yAdd = std::max(yAdd, m_avatarItem->implicitHeight()); } if (m_contentItem) { const auto contentItemWidth = diff --git a/src/timeline/messagedelegate.h b/src/timeline/messagedelegate.h index c7c7945f6..3fe717ded 100644 --- a/src/timeline/messagedelegate.h +++ b/src/timeline/messagedelegate.h @@ -101,6 +101,16 @@ class MessageDelegateBase : public TimelineDelegate */ Q_PROPERTY(QQmlComponent *quickActionComponent READ quickActionComponent WRITE setQuickActionComponent NOTIFY quickActionComponentChanged FINAL) + /** + * @brief The component to use to visualize message selection. + */ + Q_PROPERTY(QQmlComponent *selectionComponent READ selectionComponent WRITE setSelectionComponent NOTIFY selectionComponentChanged FINAL) + + /** + * @brief Whether to show the selection component. + */ + Q_PROPERTY(bool showSelection READ showSelection WRITE setShowSelection NOTIFY showSelectionChanged FINAL REQUIRED) + /** * @brief Whether to use the compact mode appearance. */ @@ -161,6 +171,11 @@ public: QQmlComponent *quickActionComponent() const; void setQuickActionComponent(QQmlComponent *quickActionComponent); + QQmlComponent *selectionComponent() const; + void setSelectionComponent(QQmlComponent *selectionComponent); + bool showSelection() const; + void setShowSelection(bool showSelection); + bool showLocalMessagesOnRight() const; void setShowLocalMessagesOnRight(bool showLocalMessagesOnRight); @@ -182,6 +197,8 @@ Q_SIGNALS: void showReadMarkersChanged(); void compactBackgroundComponentChanged(); void quickActionComponentChanged(); + void selectionComponentChanged(); + void showSelectionChanged(); void compactModeChanged(); void showLocalMessagesOnRightChanged(); void isTemporaryHighlightedChanged(); @@ -227,6 +244,12 @@ private: bool m_quickActionIncubating = false; QPointer m_quickActionItem; + QPointer m_selectionComponent; + bool m_selectionIncubating = false; + QPointer m_selectionItem; + bool m_showSelection = false; + void updateSelection(); + bool m_showLocalMessagesOnRight = true; bool m_hovered = false;