Add support for copying & deleting multiple messages at once

BUG: 496458
This commit is contained in:
Azhar Momin
2026-02-11 18:30:09 +05:30
committed by Joshua Goins
parent 0f634ff795
commit f5d726989f
10 changed files with 357 additions and 15 deletions

View File

@@ -229,6 +229,58 @@ Kirigami.Page {
Layout.fillWidth: true 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 { Kirigami.InlineMessage {
id: banner id: banner

View File

@@ -604,6 +604,7 @@ QString RoomManager::findSpaceIdForCurrentRoom() const
void RoomManager::setCurrentRoom(const QString &roomId) void RoomManager::setCurrentRoom(const QString &roomId)
{ {
if (m_currentRoom != nullptr) { if (m_currentRoom != nullptr) {
m_currentRoom->clearSelectedMessages();
m_currentRoom->disconnect(this); m_currentRoom->disconnect(this);
} }

View File

@@ -59,6 +59,8 @@
#include <KJobTrackerInterface> #include <KJobTrackerInterface>
#include <KLocalizedString> #include <KLocalizedString>
#include <ranges>
using namespace Quotient; using namespace Quotient;
std::function<bool(const Quotient::RoomEvent *)> NeoChatRoom::m_hiddenFilter = [](const Quotient::RoomEvent *) -> bool { std::function<bool(const Quotient::RoomEvent *)> 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) 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<RedactionEvent>() && !event->isStateEvent()) {
events += event->id();
}
}
doDeleteMessageIds(events, reason);
} }
QString NeoChatRoom::historyVisibility() const QString NeoChatRoom::historyVisibility() const
@@ -761,16 +770,10 @@ void NeoChatRoom::setUserPowerLevel(const QString &userID, const int &powerLevel
} }
} }
QCoro::Task<void> NeoChatRoom::doDeleteMessagesByUser(const QString &user, QString reason) QCoro::Task<void> NeoChatRoom::doDeleteMessageIds(const QStringList eventIds, QString reason)
{ {
QStringList events; for (const auto &eventId : eventIds) {
for (const auto &event : messageEvents()) { auto job = connection()->callApi<RedactEventJob>(id(), eventId, connection()->generateTxnId(), reason);
if (event->senderId() == user && !event->isRedacted() && !event.viewAs<RedactionEvent>() && !event->isStateEvent()) {
events += event->id();
}
}
for (const auto &e : events) {
auto job = connection()->callApi<RedactEventJob>(id(), QString::fromLatin1(QUrl::toPercentEncoding(e)), connection()->generateTxnId(), reason);
co_await qCoro(job.get(), &BaseJob::finished); co_await qCoro(job.get(), &BaseJob::finished);
if (job->error() != BaseJob::Success) { if (job->error() != BaseJob::Success) {
qWarning() << "Error: \"" << job->error() << "\" while deleting messages. Aborting"; qWarning() << "Error: \"" << job->error() << "\" while deleting messages. Aborting";
@@ -1963,4 +1966,96 @@ QList<QString> NeoChatRoom::sortedMemberIds() const
return m_sortedMemberIds; 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<const RoomEvent *> 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<RedactionEvent>(*event)) {
events += eventId;
}
}
doDeleteMessageIds(events, reason);
clearSelectedMessages();
}
void NeoChatRoom::clearSelectedMessages()
{
m_selectedMessageIds.clear();
Q_EMIT selectionChanged();
}
#include "moc_neochatroom.cpp" #include "moc_neochatroom.cpp"

View File

@@ -220,6 +220,16 @@ class NeoChatRoom : public Quotient::Room
*/ */
Q_PROPERTY(bool spaceHasUnreadMessages READ spaceHasUnreadMessages NOTIFY spaceHasUnreadMessagesChanged) 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: public:
explicit NeoChatRoom(Quotient::Connection *connection, QString roomId, Quotient::JoinState joinState = {}); explicit NeoChatRoom(Quotient::Connection *connection, QString roomId, Quotient::JoinState joinState = {});
@@ -676,6 +686,41 @@ public:
*/ */
QList<QString> sortedMemberIds() const; QList<QString> 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: private:
bool m_visible = false; bool m_visible = false;
@@ -693,7 +738,7 @@ private:
void onAddHistoricalTimelineEvents(rev_iter_t from) override; void onAddHistoricalTimelineEvents(rev_iter_t from) override;
void onRedaction(const Quotient::RoomEvent &prevEvent, const Quotient::RoomEvent &after) override; void onRedaction(const Quotient::RoomEvent &prevEvent, const Quotient::RoomEvent &after) override;
QCoro::Task<void> doDeleteMessagesByUser(const QString &user, QString reason); QCoro::Task<void> doDeleteMessageIds(const QStringList eventIds, QString reason);
QCoro::Task<void> doUploadFile(QUrl url, QString body = QString(), std::optional<Quotient::EventRelation> relatesTo = std::nullopt); QCoro::Task<void> doUploadFile(QUrl url, QString body = QString(), std::optional<Quotient::EventRelation> relatesTo = std::nullopt);
std::unique_ptr<Quotient::RoomEvent> m_cachedEvent; std::unique_ptr<Quotient::RoomEvent> m_cachedEvent;
@@ -713,6 +758,7 @@ private:
QString m_lastUnreadHighlightId; QString m_lastUnreadHighlightId;
QList<QString> m_sortedMemberIds; QList<QString> m_sortedMemberIds;
QSet<QString> m_selectedMessageIds;
private Q_SLOTS: private Q_SLOTS:
void updatePushNotificationState(QString type); void updatePushNotificationState(QString type);
@@ -752,6 +798,7 @@ Q_SIGNALS:
void pinnedMessageChanged(); void pinnedMessageChanged();
void highlightCycleStartedChanged(); void highlightCycleStartedChanged();
void spaceHasUnreadMessagesChanged(); void spaceHasUnreadMessagesChanged();
void selectionChanged();
/** /**
* @brief Request a message be shown to the user of the given type. * @brief Request a message be shown to the user of the given type.

View File

@@ -407,6 +407,13 @@ KirigamiComponents.ConvergentContextMenu {
onTriggered: pinned ? root.room.unpinEvent(root.eventId) : root.room.pinEvent(root.eventId) 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 { Kirigami.Action {
separator: true separator: true
visible: viewSourceAction.visible visible: viewSourceAction.visible

View File

@@ -2,6 +2,8 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com> // SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-3.0-only // SPDX-License-Identifier: GPL-3.0-only
pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import Qt.labs.qmlmodels import Qt.labs.qmlmodels
@@ -16,6 +18,11 @@ DelegateChooser {
*/ */
required property NeoChatRoom room required property NeoChatRoom room
/**
* @brief Whether to show selection controls for message delegate.
*/
property bool showSelectionControl: false
role: "delegateType" role: "delegateType"
DelegateChoice { DelegateChoice {
@@ -25,7 +32,9 @@ DelegateChooser {
DelegateChoice { DelegateChoice {
roleValue: DelegateType.Message roleValue: DelegateType.Message
delegate: MessageDelegate {} delegate: MessageDelegate {
showSelectionControl: root.showSelectionControl
}
} }
DelegateChoice { DelegateChoice {

View File

@@ -5,6 +5,7 @@ pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import QtQuick.Controls as QQC2 import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.components as KirigamiComponents import org.kde.kirigamiaddons.components as KirigamiComponents
@@ -109,6 +110,16 @@ MessageDelegateBase {
*/ */
property bool showHighlight: root.isHighlighted || isTemporaryHighlighted 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.room: root.room
Message.timeline: root.ListView.view Message.timeline: root.ListView.view
Message.contentModel: root.contentModel Message.contentModel: root.contentModel
@@ -120,6 +131,7 @@ MessageDelegateBase {
enableAvatars: NeoChatConfig?.showAvatarInTimeline ?? false enableAvatars: NeoChatConfig?.showAvatarInTimeline ?? false
compactMode: NeoChatConfig?.compactLayout ?? false compactMode: NeoChatConfig?.compactLayout ?? false
showLocalMessagesOnRight: NeoChatConfig.showLocalMessagesOnRight showLocalMessagesOnRight: NeoChatConfig.showLocalMessagesOnRight
showSelection: root.showSelectionControl && room.selectedMessageCount > 0
contentItem: Bubble { contentItem: Bubble {
id: bubble id: bubble
@@ -230,6 +242,20 @@ MessageDelegateBase {
author: root.author 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 { QtObject {
id: _private id: _private

View File

@@ -213,6 +213,7 @@ QQC2.ScrollView {
model: root.messageFilterModel model: root.messageFilterModel
delegate: EventDelegate { delegate: EventDelegate {
room: _private.room room: _private.room
showSelectionControl: true
} }
ColumnLayout { ColumnLayout {

View File

@@ -138,7 +138,10 @@ void MessageDelegateBase::setPercentageValues(bool fillWidth)
void MessageDelegateBase::setContentPadding() 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()); m_contentSizeHelper.setRightPadding(m_sizeHelper.rightPadding());
} }
@@ -539,6 +542,77 @@ void MessageDelegateBase::updateQuickAction()
m_quickActionComponent->create(*quickActionIncubator, qmlContext(m_quickActionComponent)); 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<QQuickItem *>(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 bool MessageDelegateBase::showLocalMessagesOnRight() const
{ {
return m_showLocalMessagesOnRight; return m_showLocalMessagesOnRight;
@@ -623,10 +697,17 @@ void MessageDelegateBase::resizeContent()
nextY += m_sectionItem->implicitHeight() + m_spacing; nextY += m_sectionItem->implicitHeight() + m_spacing;
} }
qreal yAdd = 0.0; 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) { 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())); m_avatarItem->setSize(QSizeF(m_avatarItem->implicitWidth(), m_avatarItem->implicitHeight()));
yAdd = m_avatarItem->implicitWidth(); yAdd = std::max(yAdd, m_avatarItem->implicitHeight());
} }
if (m_contentItem) { if (m_contentItem) {
const auto contentItemWidth = const auto contentItemWidth =

View File

@@ -101,6 +101,16 @@ class MessageDelegateBase : public TimelineDelegate
*/ */
Q_PROPERTY(QQmlComponent *quickActionComponent READ quickActionComponent WRITE setQuickActionComponent NOTIFY quickActionComponentChanged FINAL) 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. * @brief Whether to use the compact mode appearance.
*/ */
@@ -161,6 +171,11 @@ public:
QQmlComponent *quickActionComponent() const; QQmlComponent *quickActionComponent() const;
void setQuickActionComponent(QQmlComponent *quickActionComponent); void setQuickActionComponent(QQmlComponent *quickActionComponent);
QQmlComponent *selectionComponent() const;
void setSelectionComponent(QQmlComponent *selectionComponent);
bool showSelection() const;
void setShowSelection(bool showSelection);
bool showLocalMessagesOnRight() const; bool showLocalMessagesOnRight() const;
void setShowLocalMessagesOnRight(bool showLocalMessagesOnRight); void setShowLocalMessagesOnRight(bool showLocalMessagesOnRight);
@@ -182,6 +197,8 @@ Q_SIGNALS:
void showReadMarkersChanged(); void showReadMarkersChanged();
void compactBackgroundComponentChanged(); void compactBackgroundComponentChanged();
void quickActionComponentChanged(); void quickActionComponentChanged();
void selectionComponentChanged();
void showSelectionChanged();
void compactModeChanged(); void compactModeChanged();
void showLocalMessagesOnRightChanged(); void showLocalMessagesOnRightChanged();
void isTemporaryHighlightedChanged(); void isTemporaryHighlightedChanged();
@@ -227,6 +244,12 @@ private:
bool m_quickActionIncubating = false; bool m_quickActionIncubating = false;
QPointer<QQuickItem> m_quickActionItem; QPointer<QQuickItem> m_quickActionItem;
QPointer<QQmlComponent> m_selectionComponent;
bool m_selectionIncubating = false;
QPointer<QQuickItem> m_selectionItem;
bool m_showSelection = false;
void updateSelection();
bool m_showLocalMessagesOnRight = true; bool m_showLocalMessagesOnRight = true;
bool m_hovered = false; bool m_hovered = false;