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);