Skip to content

Commit

Permalink
Add rich replying and editing support
Browse files Browse the repository at this point in the history
Cc #596
Closes #447
  • Loading branch information
rpallai committed Feb 13, 2023
1 parent 0315b39 commit 55edb93
Show file tree
Hide file tree
Showing 7 changed files with 229 additions and 22 deletions.
133 changes: 111 additions & 22 deletions client/chatroomwidget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
#include "quaternionroom.h"
#include "chatedit.h"
#include "htmlfilter.h"
#include "models/messageeventmodel.h"

static auto DefaultPlaceholderText()
{
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -262,6 +271,7 @@ void ChatRoomWidget::setRoom(QuaternionRoom* newRoom)
}
typingChanged();
encryptionChanged();
setDefaultMode();
}

void ChatRoomWidget::typingChanged()
Expand Down Expand Up @@ -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 <br/> 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()
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -789,6 +859,25 @@ 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)
{
// Might it be a good idea to detect markdown text in the plain text body
// here and if found then present that for the user instead of the rich
// body?
auto htmlText = modelIndex.data(MessageEventModel::NudeRichBodyRole).toString();

m_chatEdit->clear();
m_chatEdit->insertHtml(htmlText);
setReferringMode(Editing, modelIndex, "edit-entry");
setHudHtml(tr("Edit message"));
}

void ChatRoomWidget::resizeEvent(QResizeEvent*)
{
m_chatEdit->setMaximumHeight(maximumChatEditHeight());
Expand Down
18 changes: 18 additions & 0 deletions client/chatroomwidget.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
#include <settings.h>

#include <QtWidgets/QWidget>
#include <QtWidgets/QToolButton>
#include <QtCore/QModelIndex>

class TimelineWidget;
class QuaternionRoom;
Expand All @@ -41,6 +43,12 @@ class User;

class ChatRoomWidget : public QWidget
{
enum Modes {
Default,
Replying,
Editing,
};

Q_OBJECT
public:
using completions_t = ChatEdit::completions_t;
Expand All @@ -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);
Expand All @@ -74,20 +84,28 @@ 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;

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;
Expand Down
69 changes: 69 additions & 0 deletions client/models/messageeventmodel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
#include <connection.h>
#include <user.h>
#include <settings.h>
#include <uri.h>
#include <events/encryptionevent.h>
#include <events/roommemberevent.h>
#include <events/simplestateevents.h>
Expand Down Expand Up @@ -59,6 +60,9 @@ QHash<int, QByteArray> 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;
Expand Down Expand Up @@ -919,5 +923,70 @@ 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)
{
if (auto e = eventCast<const Quotient::RoomMessageEvent>(&evt)) {
if (!e->hasTextContent())
return QString();

static const QRegularExpression quoteBlock {
"<mx-reply>.*</mx-reply>",
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<const MessageEventContent::TextContent*>(e->content())->body;
auto [cleanHtml, errorPos, errorString] =
HtmlFilter::fromMatrixHtml(htmlBody, m_currentRoom);
if (errorPos == -1) {
nudeBody = cleanHtml.remove(quoteBlock);
}
}
if (nudeBody.isEmpty()) {
nudeBody = m_currentRoom->prettyPrint(e->plainBody().remove(quoteLines));
}
return nudeBody;
}
}

if (role == QuotationRole)
{
if (auto e = eventCast<const Quotient::RoomMessageEvent>(&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<const Quotient::RoomMessageEvent>(&evt)) {
QString quotation = data(idx, NudeRichBodyRole).toString();
if (quotation.isEmpty())
return QString();
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(
"<mx-reply><blockquote><a href=\"%1\">In reply to</a> <a href=\"%2\">%3</a><br />%4</blockquote></mx-reply>"
).arg(evtLink, authorLink, authorName, quotation);
}
}

return {};
}
3 changes: 3 additions & 0 deletions client/models/messageeventmodel.h
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ class MessageEventModel: public QAbstractListModel
RefRole,
ReactionsRole,
EventResolvedTypeRole,
NudeRichBodyRole,
QuotationRole,
HtmlQuotationRole,
};

explicit MessageEventModel(QObject* parent = nullptr);
Expand Down
15 changes: 15 additions & 0 deletions client/qml/Timeline.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -466,6 +479,8 @@ Page {
- sectionBanner.childrenRect.height)
onViewPositionRequested:
chatView.scrollViewTo(index, ListView.Contain, true)
onSetCurrentIndex:
chatView.currentIndex = index
}

Component.onCompleted: {
Expand Down
Loading

0 comments on commit 55edb93

Please sign in to comment.