diff --git a/client/chatroomwidget.cpp b/client/chatroomwidget.cpp index 19ee29a8..8e13d85c 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,79 @@ 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, + enum TextFormat textFormat) +{ + 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; - } + ((textFormat == Unspecified + && m_uiSettings.get("auto_markdown", false)) + || textFormat == 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( + referencedEventIndex().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; + auto reference = referencedEventIndex(); + 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() @@ -651,7 +702,8 @@ QString ChatRoomWidget::sendCommand(QStringView command, const auto& plainMsg = m_chatEdit->toPlainText().mid(CmdLen); if (plainMsg.isEmpty()) return NothingToSendMsg(); - currentRoom()->postPlainText(plainMsg); + const auto& fragment = QTextDocumentFragment::fromPlainText(plainMsg); + sendMessageFromFragment(fragment, Plaintext); return {}; } if (command == u"html") @@ -670,9 +722,7 @@ QString ChatRoomWidget::sendCommand(QStringView command, .arg(errorPos).arg(errorString); const auto& fragment = QTextDocumentFragment::fromHtml(cleanQtHtml); - currentRoom()->postHtmlText(fragment.toPlainText(), - HtmlFilter::toMatrixHtml(fragment.toHtml(), - currentRoom())); + sendMessageFromFragment(fragment, Html); return {}; } if (command == u"md") { @@ -682,7 +732,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(), Markdown); return {}; #else return tr("Your build of Quaternion doesn't support Markdown"); @@ -731,6 +781,44 @@ void ChatRoomWidget::sendInput() } m_chatEdit->saveInput(); + setDefaultMode(); +} + +void ChatRoomWidget::setDefaultMode() +{ + mode = Default; + emit m_timelineWidget->setCurrentIndex(-1); + referencedEventId = ""; + m_modeIndicator->hide(); +} + +bool ChatRoomWidget::setReferringMode(const int newMode, const QString& eventId, + const char* icon_name) +{ + Q_ASSERT( newMode == Replying || newMode == Editing ); + // Actually, we could let the user to refer to pending events too but in + // this case we would need a universal pointer instead of event id. Now the + // user cannot start to edit a pending message which might be annoying if + // transactions are acknowledged slowly. + auto idx = m_timelineWidget->indexOf(eventId); + if (!idx.isValid()) + return false; + mode = newMode; + referencedEventId = eventId; + emit m_timelineWidget->setCurrentIndex(idx.row()); + + m_modeIndicator->setIcon(QIcon::fromTheme(icon_name)); + + m_modeIndicator->show(); + return true; +} + +QModelIndex ChatRoomWidget::referencedEventIndex() +{ + Q_ASSERT(!referencedEventId.isEmpty()); + auto idx = m_timelineWidget->indexOf(referencedEventId); + Q_ASSERT(idx.isValid()); + return idx; } ChatRoomWidget::completions_t @@ -789,6 +877,36 @@ void ChatRoomWidget::quote(const QString& htmlText) m_chatEdit->insertPlainText(sendString); } +void ChatRoomWidget::reply(const QString& eventId) +{ + if (!setReferringMode(Replying, eventId, "mail-reply-sender")) { + setHudHtml(tr("Referenced message not suitable for replying (yet)")); + return; + } + setHudHtml(tr("Reply message")); +} + +void ChatRoomWidget::edit(const QString& eventId) +{ + if (!setReferringMode(Editing, eventId, "edit-entry")) { + setHudHtml(tr("Referenced message not suitable for editing (yet)")); + return; + } + + auto htmlText = referencedEventIndex() + .data(MessageEventModel::NudeRichBodyRole) + .toString(); + m_chatEdit->clear(); + // We can never be sure which input format was used to build this message. + // It can be markdown, matrixhtml (`/html`), rich text paste or a mixture of + // these. Perhaps the best solution is to introduce a generic format + // converter into ChatEdit's contextmenu which can be used any time by the + // user. By using it, the user could convert this rich text to the desired + // format. + m_chatEdit->insertHtml(htmlText); + setHudHtml(tr("Edit message")); +} + void ChatRoomWidget::resizeEvent(QResizeEvent*) { m_chatEdit->setMaximumHeight(maximumChatEditHeight()); diff --git a/client/chatroomwidget.h b/client/chatroomwidget.h index 4173deec..be417bdc 100644 --- a/client/chatroomwidget.h +++ b/client/chatroomwidget.h @@ -24,6 +24,8 @@ #include #include +#include +#include class TimelineWidget; class QuaternionRoom; @@ -41,6 +43,18 @@ class User; class ChatRoomWidget : public QWidget { + enum Modes { + Default, + Replying, + Editing, + }; + enum TextFormat { + Unspecified, + Markdown, + Plaintext, + Html, + }; + Q_OBJECT public: using completions_t = ChatEdit::completions_t; @@ -63,6 +77,8 @@ class ChatRoomWidget : public QWidget void typingChanged(); void quote(const QString& htmlText); + void edit(const QString& eventId); + void reply(const QString& eventId); void fileDrop(const QString& url); void htmlDrop(const QString& html); void textDrop(const QString& text); @@ -74,9 +90,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; + QString referencedEventId; + QString attachedFileName; QTemporaryFile* m_fileToAttach; Quotient::SettingsGroup m_uiSettings; @@ -84,10 +104,17 @@ class ChatRoomWidget : public QWidget MainWindow* mainWindow() const; QuaternionRoom* currentRoom() const; + void setDefaultMode(); + bool setReferringMode(const int newMode, const QString& eventId, + const char* icon_name); + QModelIndex referencedEventIndex(); + void sendFile(); void sendMessage(); [[nodiscard]] QString sendCommand(QStringView command, const QString& argString); + void sendMessageFromFragment(const QTextDocumentFragment& text, + enum TextFormat textFormat = Unspecified); void resizeEvent(QResizeEvent*) override; void keyPressEvent(QKeyEvent* event) override; diff --git a/client/models/messageeventmodel.cpp b/client/models/messageeventmodel.cpp index b7adbfae..4274f92b 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(NudeRichBodyRole, "nudeRichBody"); + roles.insert(QuotationRole, "quotation"); + roles.insert(HtmlQuotationRole, "htmlQuotation"); return roles; }(); return roles; @@ -919,5 +923,71 @@ 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 == NudeRichBodyRole) + { + auto e = eventCast(&evt); + if (!e || !e->hasTextContent()) + return QString(); + + static const QRegularExpression quoteBlock { + ".*", + QRegularExpression::DotMatchesEverythingOption + }; + static const QRegularExpression quoteLines("> .*(?:\n|$)"); + QString nudeBody; + if (e->mimeType().name() != "text/plain") { + // Naïvely assume that it's HTML + auto htmlBody = + static_cast(e->content())->body; + auto [cleanHtml, errorPos, errorString] = + HtmlFilter::fromMatrixHtml(htmlBody.remove(quoteBlock), m_currentRoom); + if (errorPos == -1) { + nudeBody = cleanHtml; + } + } + if (nudeBody.isEmpty()) { + nudeBody = m_currentRoom->prettyPrint(e->plainBody().remove(quoteLines)); + } + return nudeBody; + } + + if (role == QuotationRole) + { + auto e = eventCast(&evt); + if (!e || !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 (isPending) + return QString(); // Cannot construct event link with unknown eventId + QString quotation = data(idx, NudeRichBodyRole).toString(); + if (quotation.isEmpty()) + return QString(); + const auto authorUser = m_currentRoom->user(evt.senderId()); + const QString evtLink = + "https://matrix.to/#/" + m_currentRoom->id() + "/" + evt.id(); + 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..e7dce6ea 100644 --- a/client/models/messageeventmodel.h +++ b/client/models/messageeventmodel.h @@ -45,6 +45,9 @@ class MessageEventModel: public QAbstractListModel RefRole, ReactionsRole, EventResolvedTypeRole, + NudeRichBodyRole, + 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..63394f29 100644 --- a/client/timelinewidget.cpp +++ b/client/timelinewidget.cpp @@ -73,6 +73,15 @@ QuaternionRoom* TimelineWidget::currentRoom() const return m_messageModel->room(); } +QModelIndex TimelineWidget::indexOf(const QString& eventId) const +{ + auto row = m_messageModel->findRow(eventId); + if (row >= 0) + return m_messageModel->index(row); + else + return QModelIndex(); +} + void TimelineWidget::setRoom(QuaternionRoom* newRoom) { if (currentRoom() == newRoom) @@ -193,6 +202,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("mail-reply-sender"), + tr("Reply"), + [this, eventId] { + roomWidget->reply(eventId); + }); + if (localUserId == modelUser->id()) { + menu->addAction(QIcon::fromTheme("edit-entry"), + tr("Edit"), + [this, eventId] { + roomWidget->edit(eventId); + }); + } 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..b64c9679 100644 --- a/client/timelinewidget.h +++ b/client/timelinewidget.h @@ -19,6 +19,7 @@ class TimelineWidget : public QQuickWidget { ~TimelineWidget() override; QString selectedText() const; QuaternionRoom* currentRoom() const; + QModelIndex indexOf(const QString& eventId) const; signals: void resourceRequested(const QString& idOrUri, const QString& action = {}); @@ -30,6 +31,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);