From 60c550f121a9e43ad175ace8890e16393a92c5a0 Mon Sep 17 00:00:00 2001 From: Roland Pallai Date: Mon, 6 Feb 2023 13:25:09 +0100 Subject: [PATCH] Add replying and editing support Cc #596 Closes #447 --- client/chatroomwidget.cpp | 130 +++++++++++++++++++++++----- client/chatroomwidget.h | 18 ++++ client/models/messageeventmodel.cpp | 67 ++++++++++++++ client/models/messageeventmodel.h | 3 + client/qml/Timeline.qml | 15 ++++ client/timelinewidget.cpp | 12 +++ client/timelinewidget.h | 1 + 7 files changed, 224 insertions(+), 22 deletions(-) diff --git a/client/chatroomwidget.cpp b/client/chatroomwidget.cpp index 19ee29a8..07bc4616 100644 --- a/client/chatroomwidget.cpp +++ b/client/chatroomwidget.cpp @@ -52,6 +52,7 @@ #include "quaternionroom.h" #include "chatedit.h" #include "htmlfilter.h" +#include "models/messageeventmodel.h" static auto DefaultPlaceholderText() { @@ -84,6 +85,13 @@ ChatRoomWidget::ChatRoomWidget(MainWindow* parent) m_hudCaption->setFont(f); m_hudCaption->setTextFormat(Qt::RichText); + m_modeIndicator = new QToolButton(); + m_modeIndicator->setAutoRaise(true); + m_modeIndicator->hide(); + connect(m_modeIndicator, &QToolButton::clicked, this, [this] { + setDefaultMode(); + }); + auto attachButton = new QToolButton(); attachButton->setAutoRaise(true); m_attachAction = new QAction(QIcon::fromTheme("mail-attachment"), @@ -207,6 +215,7 @@ ChatRoomWidget::ChatRoomWidget(MainWindow* parent) layout->addWidget(m_hudCaption); { auto inputLayout = new QHBoxLayout; + inputLayout->addWidget(m_modeIndicator); inputLayout->addWidget(attachButton); inputLayout->addWidget(m_chatEdit); layout->addLayout(inputLayout); @@ -262,6 +271,7 @@ void ChatRoomWidget::setRoom(QuaternionRoom* newRoom) } typingChanged(); encryptionChanged(); + setDefaultMode(); } void ChatRoomWidget::typingChanged() @@ -416,38 +426,77 @@ void ChatRoomWidget::sendFile() m_chatEdit->setPlaceholderText(DefaultPlaceholderText()); } -#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) -void sendMarkdown(QuaternionRoom* room, const QTextDocumentFragment& text) -{ - room->postHtmlText(text.toPlainText(), - HtmlFilter::toMatrixHtml(text.toHtml(), room, - HtmlFilter::ConvertMarkdown)); -} -#endif - void ChatRoomWidget::sendMessage() { if (m_chatEdit->toPlainText().startsWith("//")) QTextCursor(m_chatEdit->document()).deleteChar(); + QTextCursor c(m_chatEdit->document()); + c.select(QTextCursor::Document); + sendMessageFromFragment(c.selection()); +} + +void ChatRoomWidget::sendMessageFromFragment(const QTextDocumentFragment& text, bool markdown) +{ + const auto& plainText = text.toPlainText(); + const auto& htmlText = + HtmlFilter::toMatrixHtml(text.toHtml(), currentRoom(), #if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) - if (m_uiSettings.get("auto_markdown", false)) { - sendMarkdown(currentRoom(), - QTextDocumentFragment(m_chatEdit->document())); - return; - } + (m_uiSettings.get("auto_markdown", false) || markdown) ? + HtmlFilter::ConvertMarkdown : #endif - const auto& plainText = m_chatEdit->toPlainText(); - const auto& htmlText = - HtmlFilter::toMatrixHtml(m_chatEdit->toHtml(), currentRoom()); + HtmlFilter::Default + ); Q_ASSERT(!plainText.isEmpty() && !htmlText.isEmpty()); // Send plain text if htmlText has no markup or just
elements // (those are easily represented as line breaks in plain text) static const QRegularExpression MarkupRE { "<(?![Bb][Rr])" }; - if (htmlText.contains(MarkupRE)) - currentRoom()->postHtmlText(plainText, htmlText); - else - currentRoom()->postPlainText(plainText); + + using namespace Quotient; + switch (mode) { + case Editing: + { + // Any quotation is ignored intentionally, see + // https://spec.matrix.org/latest/client-server-api/#edits-of-replies + auto eventRelation = EventRelation::replace( + reference.data(MessageEventModel::EventIdRole).toString() + ); + EventContent::TextContent* textContent; + if (htmlText.contains(MarkupRE)) { + textContent = new EventContent::TextContent(htmlText, + QStringLiteral("text/html"), eventRelation); + } else { + textContent = new EventContent::TextContent("", + QStringLiteral("text/plain"), eventRelation); + } + auto roomMessageEvent = new RoomMessageEvent(plainText, + MessageEventType::Text, textContent); + currentRoom()->postEvent(roomMessageEvent); + } + break; + case Replying: + { + QString htmlQuotation, plainTextQuotation; + if (reference.isValid()) { + htmlQuotation = reference.data(MessageEventModel::HtmlQuotationRole).toString(); + plainTextQuotation = reference.data(MessageEventModel::QuotationRole).toString(); + } + auto textContent = new EventContent::TextContent(htmlQuotation + htmlText, + QStringLiteral("text/html"), + EventRelation::replyTo( + reference.data(MessageEventModel::EventIdRole).toString() + )); + auto roomMessageEvent = new RoomMessageEvent(plainTextQuotation + plainText, + MessageEventType::Text, textContent); + currentRoom()->postEvent(roomMessageEvent); + } + break; + default: + if (htmlText.contains(MarkupRE)) + currentRoom()->postHtmlText(plainText, htmlText); + else + currentRoom()->postPlainText(plainText); + } } static auto NothingToSendMsg() @@ -682,7 +731,7 @@ QString ChatRoomWidget::sendCommand(QStringView command, QTextCursor c(m_chatEdit->document()); c.movePosition(QTextCursor::Right, QTextCursor::MoveAnchor, 4); c.movePosition(QTextCursor::End, QTextCursor::KeepAnchor); - sendMarkdown(currentRoom(), c.selection()); + sendMessageFromFragment(c.selection(), true); return {}; #else return tr("Your build of Quaternion doesn't support Markdown"); @@ -731,6 +780,27 @@ void ChatRoomWidget::sendInput() } m_chatEdit->saveInput(); + setDefaultMode(); +} + +void ChatRoomWidget::setDefaultMode() +{ + mode = Default; + emit m_timelineWidget->setCurrentIndex(-1); + reference = QModelIndex(); + m_modeIndicator->hide(); +} + +void ChatRoomWidget::setReferringMode(const int newMode, const QModelIndex& referredIndex, const char *icon_name) +{ + Q_ASSERT( newMode == Replying || newMode == Editing ); + mode = newMode; + reference = referredIndex; + emit m_timelineWidget->setCurrentIndex(referredIndex.row()); + + m_modeIndicator->setIcon(QIcon::fromTheme(icon_name)); + + m_modeIndicator->show(); } ChatRoomWidget::completions_t @@ -789,6 +859,22 @@ void ChatRoomWidget::quote(const QString& htmlText) m_chatEdit->insertPlainText(sendString); } +void ChatRoomWidget::reply(const QString& eventId, const QModelIndex& modelIndex) +{ + setReferringMode(Replying, modelIndex, "mail-reply-sender"); + setHudHtml(tr("Reply message")); +} + +void ChatRoomWidget::edit(const QString& eventId, const QModelIndex& modelIndex) +{ + QTextDocument document; + + m_chatEdit->clear(); + m_chatEdit->insertPlainText(modelIndex.data(MessageEventModel::NudeBodyRole).toString()); + setReferringMode(Editing, modelIndex, "edit-entry"); + setHudHtml(tr("Edit message")); +} + void ChatRoomWidget::resizeEvent(QResizeEvent*) { m_chatEdit->setMaximumHeight(maximumChatEditHeight()); diff --git a/client/chatroomwidget.h b/client/chatroomwidget.h index 4173deec..fe97ace3 100644 --- a/client/chatroomwidget.h +++ b/client/chatroomwidget.h @@ -24,6 +24,8 @@ #include #include +#include +#include class TimelineWidget; class QuaternionRoom; @@ -41,6 +43,12 @@ class User; class ChatRoomWidget : public QWidget { + enum Modes { + Default, + Replying, + Editing, + }; + Q_OBJECT public: using completions_t = ChatEdit::completions_t; @@ -63,6 +71,8 @@ class ChatRoomWidget : public QWidget void typingChanged(); void quote(const QString& htmlText); + void edit(const QString& eventId, const QModelIndex& modelIndex); + void reply(const QString& eventId, const QModelIndex& modelIndex); void fileDrop(const QString& url); void htmlDrop(const QString& html); void textDrop(const QString& text); @@ -74,9 +84,13 @@ class ChatRoomWidget : public QWidget private: TimelineWidget* m_timelineWidget; QLabel* m_hudCaption; //< For typing and completion notifications + QToolButton* m_modeIndicator; QAction* m_attachAction; ChatEdit* m_chatEdit; + int mode; + QModelIndex reference; + QString attachedFileName; QTemporaryFile* m_fileToAttach; Quotient::SettingsGroup m_uiSettings; @@ -84,10 +98,14 @@ class ChatRoomWidget : public QWidget MainWindow* mainWindow() const; QuaternionRoom* currentRoom() const; + void setDefaultMode(); + void setReferringMode(const int newMode, const QModelIndex& referredIndex, const char *icon_name); + void sendFile(); void sendMessage(); [[nodiscard]] QString sendCommand(QStringView command, const QString& argString); + void sendMessageFromFragment(const QTextDocumentFragment& text, bool markdown = false); void resizeEvent(QResizeEvent*) override; void keyPressEvent(QKeyEvent* event) override; diff --git a/client/models/messageeventmodel.cpp b/client/models/messageeventmodel.cpp index b7adbfae..e0e3e81e 100644 --- a/client/models/messageeventmodel.cpp +++ b/client/models/messageeventmodel.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -59,6 +60,9 @@ QHash MessageEventModel::roleNames() const roles.insert(EventResolvedTypeRole, "eventResolvedType"); roles.insert(RefRole, "refId"); roles.insert(ReactionsRole, "reactions"); + roles.insert(NudeBodyRole, "nudeBody"); + roles.insert(QuotationRole, "quotation"); + roles.insert(HtmlQuotationRole, "htmlQuotation"); return roles; }(); return roles; @@ -919,5 +923,68 @@ QVariant MessageEventModel::data(const QModelIndex& idx, int role) const evt, [](const RoomCreateEvent& e) { return e.predecessor().roomId; }, [](const RoomTombstoneEvent& e) { return e.successorRoomId(); }); + if (role == NudeBodyRole) + { + if (auto e = eventCast(&evt)) { + if (!e->hasTextContent()) + return QString(); + + static const QRegularExpression quoteLines("> .*(?:\n|$)"); + return e->plainBody().remove(quoteLines); + } + } + + if (role == QuotationRole) + { + if (auto e = eventCast(&evt)) { + if (!e->hasTextContent()) + return QString(); + + static const QRegularExpression quoteLines("> .*(?:\n|$)"); + static const QRegularExpression eachLine("(.+)(?:\n|$)"); + const auto quotePrefix = QStringLiteral("> \\1\n"); + const auto authorUser = isPending ? m_currentRoom->localUser() : m_currentRoom->user(evt.senderId()); + const auto authorId = authorUser->id(); + + QString quotation = e->plainBody().remove(quoteLines); + return QStringLiteral("<%1> %2").arg(authorId, quotation). + replace(eachLine, quotePrefix); + } + } + + if (role == HtmlQuotationRole) + { + if (auto e = eventCast(&evt)) { + static const QRegularExpression quoteBlock{".*"}; + static const QRegularExpression quoteLines("> .*(?:\n|$)"); + if (!e->hasTextContent()) + return QString(); + + QString quotation; + if (e->mimeType().name() != "text/plain") { + // Naïvely assume that it's HTML + const auto htmlBody = + static_cast(e->content())->body; + auto [cleanHtml, errorPos, errorString] = + HtmlFilter::fromMatrixHtml(htmlBody, m_currentRoom); + if (errorPos == -1) { + quotation = cleanHtml.remove(quoteBlock); + } + } + if (quotation.isEmpty()) { + quotation = m_currentRoom->prettyPrint(e->plainBody().remove(quoteLines)); + } + const auto authorUser = isPending ? m_currentRoom->localUser() : m_currentRoom->user(evt.senderId()); + const QString eventId = !evt.id().isEmpty() ? evt.id() : evt.transactionId(); + const QString evtLink = "https://matrix.to/#/" + m_currentRoom->id() + "/" + eventId; + const QString authorName = authorUser->displayname(m_currentRoom); + const QString authorLink = Uri(authorUser->id()).toUrl(Uri::MatrixToUri).toString(); + + return QStringLiteral( + "
In reply to %3
%4
" + ).arg(evtLink, authorLink, authorName, quotation); + } + } + return {}; } diff --git a/client/models/messageeventmodel.h b/client/models/messageeventmodel.h index 067a9c8a..252ab1c4 100644 --- a/client/models/messageeventmodel.h +++ b/client/models/messageeventmodel.h @@ -45,6 +45,9 @@ class MessageEventModel: public QAbstractListModel RefRole, ReactionsRole, EventResolvedTypeRole, + NudeBodyRole, + QuotationRole, + HtmlQuotationRole, }; explicit MessageEventModel(QObject* parent = nullptr); diff --git a/client/qml/Timeline.qml b/client/qml/Timeline.qml index 926d60ee..3ec4b633 100644 --- a/client/qml/Timeline.qml +++ b/client/qml/Timeline.qml @@ -242,6 +242,19 @@ Page { boundsMovement: Flickable.StopAtBounds // pixelAligned: true // Causes false-negatives in atYEnd cacheBuffer: 200 + highlight: Component { + Rectangle { + color: defaultPalette.highlight + radius: 5 + Behavior on y { + SpringAnimation { + spring: 3 + damping: 0.2 + } + } + } + } + highlightFollowsCurrentItem: true clip: true ScrollBar.vertical: ScrollBar { @@ -466,6 +479,8 @@ Page { - sectionBanner.childrenRect.height) onViewPositionRequested: chatView.scrollViewTo(index, ListView.Contain, true) + onSetCurrentIndex: + chatView.currentIndex = index } Component.onCompleted: { diff --git a/client/timelinewidget.cpp b/client/timelinewidget.cpp index 24da80c4..8a468bbf 100644 --- a/client/timelinewidget.cpp +++ b/client/timelinewidget.cpp @@ -193,6 +193,18 @@ void TimelineWidget::showMenu(int index, const QString& hoveredLink, const int userPl = plEvt ? plEvt->powerLevelForUser(localUserId) : 0; const auto* modelUser = modelIndex.data(MessageEventModel::AuthorRole).value(); + menu->addAction(QIcon::fromTheme("reply"), + tr("Reply"), + [this, eventId, modelIndex] { + roomWidget->reply(eventId, modelIndex); + }); + if (localUserId == modelUser->id()) { + menu->addAction(QIcon::fromTheme("edit"), + tr("Edit"), + [this, eventId, modelIndex] { + roomWidget->edit(eventId, modelIndex); + }); + } if (!plEvt || userPl >= plEvt->redact() || localUserId == modelUser->id()) menu->addAction(QIcon::fromTheme("edit-delete"), tr("Redact"), this, [this, eventId] { currentRoom()->redactEvent(eventId); }); diff --git a/client/timelinewidget.h b/client/timelinewidget.h index cccf4fc9..fcf28b2a 100644 --- a/client/timelinewidget.h +++ b/client/timelinewidget.h @@ -30,6 +30,7 @@ class TimelineWidget : public QQuickWidget { void showDetails(int currentIndex); void viewPositionRequested(int index); void animateMessage(int currentIndex); + void setCurrentIndex(int index); public slots: void setRoom(QuaternionRoom* room);