diff --git a/include/ChatPage.h b/include/ChatPage.h index c753aa97..cd516a71 100644 --- a/include/ChatPage.h +++ b/include/ChatPage.h @@ -72,6 +72,10 @@ public: { client_->readEvent(room_id, event_id); } + void redactEvent(const QString &room_id, const QString &event_id) + { + client_->redactEvent(room_id, event_id); + } QSharedPointer userSettings() { return userSettings_; } diff --git a/include/MatrixClient.h b/include/MatrixClient.h index 69fa72bc..3052a118 100644 --- a/include/MatrixClient.h +++ b/include/MatrixClient.h @@ -86,6 +86,7 @@ public: void sendTypingNotification(const QString &roomid, int timeoutInMillis = 20000); void removeTypingNotification(const QString &roomid); void readEvent(const QString &room_id, const QString &event_id); + void redactEvent(const QString &room_id, const QString &event_id); void inviteUser(const QString &room_id, const QString &user); void createRoom(const mtx::requests::CreateRoom &request); @@ -171,6 +172,9 @@ signals: void leftRoom(const QString &room_id); void roomCreationFailed(const QString &msg); + void redactionFailed(const QString &error); + void redactionCompleted(const QString &room_id, const QString &event_id); + private: QNetworkReply *makeUploadRequest(QSharedPointer iodev); QJsonObject getUploadReply(QNetworkReply *reply); diff --git a/include/timeline/TimelineItem.h b/include/timeline/TimelineItem.h index 7c04e167..ade2f834 100644 --- a/include/timeline/TimelineItem.h +++ b/include/timeline/TimelineItem.h @@ -93,6 +93,9 @@ public: ChatPage::instance()->readEvent(room_id_, event_id_); } + //! Add a user avatar for this event. + void addAvatar(); + protected: void paintEvent(QPaintEvent *event) override; void contextMenuEvent(QContextMenuEvent *event) override; @@ -130,20 +133,18 @@ private: QMenu *contextMenu_; QAction *showReadReceipts_; QAction *markAsRead_; + QAction *redactMsg_; - QHBoxLayout *topLayout_; - //! The message and the timestamp/checkmark. - QHBoxLayout *messageLayout_; - //! Avatar or Timestamp - QVBoxLayout *sideLayout_; - //! Header & Message body - QVBoxLayout *mainLayout_; - - QVBoxLayout *headerLayout_; // Username (&) Timestamp + QHBoxLayout *topLayout_ = nullptr; + QHBoxLayout *messageLayout_ = nullptr; + QVBoxLayout *mainLayout_ = nullptr; + QVBoxLayout *headerLayout_ = nullptr; + QHBoxLayout *widgetLayout_ = nullptr; Avatar *userAvatar_; QFont font_; + QFont usernameFont_; QLabel *timestamp_; QLabel *checkmark_; @@ -169,26 +170,23 @@ TimelineItem::setupLocalWidgetLayout(Widget *widget, generateTimestamp(timestamp); - auto widgetLayout = new QHBoxLayout(); - widgetLayout->setContentsMargins(0, 5, 0, 0); - widgetLayout->addWidget(widget); - widgetLayout->addStretch(1); - - messageLayout_->setContentsMargins(0, 0, 20, 4); - messageLayout_->setSpacing(20); + widgetLayout_ = new QHBoxLayout; + widgetLayout_->setContentsMargins(0, 5, 0, 0); + widgetLayout_->addWidget(widget); + widgetLayout_->addStretch(1); if (withSender) { generateBody(displayName, ""); setupAvatarLayout(displayName); - headerLayout_->addLayout(widgetLayout); + headerLayout_->addLayout(widgetLayout_); messageLayout_->addLayout(headerLayout_, 1); AvatarProvider::resolve(userid, [this](const QImage &img) { setUserAvatar(img); }); } else { setupSimpleLayout(); - messageLayout_->addLayout(widgetLayout, 1); + messageLayout_->addLayout(widgetLayout_, 1); } messageLayout_->addWidget(checkmark_); @@ -220,26 +218,23 @@ TimelineItem::setupWidgetLayout(Widget *widget, generateTimestamp(timestamp); - auto widgetLayout = new QHBoxLayout(); - widgetLayout->setContentsMargins(0, 5, 0, 0); - widgetLayout->addWidget(widget); - widgetLayout->addStretch(1); - - messageLayout_->setContentsMargins(0, 0, 20, 4); - messageLayout_->setSpacing(20); + widgetLayout_ = new QHBoxLayout(); + widgetLayout_->setContentsMargins(0, 5, 0, 0); + widgetLayout_->addWidget(widget); + widgetLayout_->addStretch(1); if (withSender) { generateBody(displayName, ""); setupAvatarLayout(displayName); - headerLayout_->addLayout(widgetLayout); + headerLayout_->addLayout(widgetLayout_); messageLayout_->addLayout(headerLayout_, 1); AvatarProvider::resolve(sender, [this](const QImage &img) { setUserAvatar(img); }); } else { setupSimpleLayout(); - messageLayout_->addLayout(widgetLayout, 1); + messageLayout_->addLayout(widgetLayout_, 1); } messageLayout_->addWidget(checkmark_); diff --git a/include/timeline/TimelineView.h b/include/timeline/TimelineView.h index 2876cc60..78e092b3 100644 --- a/include/timeline/TimelineView.h +++ b/include/timeline/TimelineView.h @@ -101,6 +101,9 @@ public: void scrollDown(); QLabel *createDateSeparator(QDateTime datetime); + //! Remove an item from the timeline with the given Event ID. + void removeEvent(const QString &event_id); + public slots: void sliderRangeChanged(int min, int max); void sliderMoved(int position); @@ -128,6 +131,8 @@ protected: private: using TimelineEvent = mtx::events::collections::TimelineEvents; + QWidget *relativeWidget(TimelineItem *item, int dt) const; + //! HACK: Fixing layout flickering when adding to the bottom //! of the timeline. void pushTimelineItem(TimelineItem *item) @@ -232,7 +237,7 @@ private: inline bool isNotifiable(const TimelineEvent &event) const; // The events currently rendered. Used for duplicate detection. - QMap eventIds_; + QMap eventIds_; QQueue pending_msgs_; QList pending_sent_msgs_; QSharedPointer client_; @@ -295,13 +300,9 @@ TimelineView::processMessageEvent(const Event &event, TimelineDirection directio const auto event_id = QString::fromStdString(event.event_id); const auto sender = QString::fromStdString(event.sender); - if (isDuplicate(event_id)) - return nullptr; - - eventIds_[event_id] = true; - const QString txnid = QString::fromStdString(event.unsigned_data.transaction_id); - if (!txnid.isEmpty() && isPendingMessage(txnid, sender, local_user_)) { + if ((!txnid.isEmpty() && isPendingMessage(txnid, sender, local_user_)) || + isDuplicate(event_id)) { removePendingMessage(txnid); return nullptr; } @@ -310,7 +311,11 @@ TimelineView::processMessageEvent(const Event &event, TimelineDirection directio updateLastSender(sender, direction); - return createTimelineItem(event, with_sender); + auto item = createTimelineItem(event, with_sender); + + eventIds_[event_id] = item; + + return item; } template @@ -320,13 +325,9 @@ TimelineView::processMessageEvent(const Event &event, TimelineDirection directio const auto event_id = QString::fromStdString(event.event_id); const auto sender = QString::fromStdString(event.sender); - if (isDuplicate(event_id)) - return nullptr; - - eventIds_[event_id] = true; - const QString txnid = QString::fromStdString(event.unsigned_data.transaction_id); - if (!txnid.isEmpty() && isPendingMessage(txnid, sender, local_user_)) { + if ((!txnid.isEmpty() && isPendingMessage(txnid, sender, local_user_)) || + isDuplicate(event_id)) { removePendingMessage(txnid); return nullptr; } @@ -335,5 +336,9 @@ TimelineView::processMessageEvent(const Event &event, TimelineDirection directio updateLastSender(sender, direction); - return createTimelineItem(event, with_sender); + auto item = createTimelineItem(event, with_sender); + + eventIds_[event_id] = item; + + return item; } diff --git a/src/ChatPage.cc b/src/ChatPage.cc index fee4f982..158427fd 100644 --- a/src/ChatPage.cc +++ b/src/ChatPage.cc @@ -342,6 +342,9 @@ ChatPage::ChatPage(QSharedPointer client, emit showNotification(QString("Room %1 created").arg(room_id)); }); connect(client_.data(), &MatrixClient::leftRoom, this, &ChatPage::removeRoom); + connect(client_.data(), &MatrixClient::redactionFailed, this, [this](const QString &error) { + emit showNotification(QString("Message redaction failed: %1").arg(error)); + }); showContentTimer_ = new QTimer(this); showContentTimer_->setSingleShot(true); diff --git a/src/MatrixClient.cc b/src/MatrixClient.cc index 4a4fc67c..5f9e1b86 100644 --- a/src/MatrixClient.cc +++ b/src/MatrixClient.cc @@ -1281,3 +1281,47 @@ MatrixClient::getUploadReply(QNetworkReply *reply) return object; } + +void +MatrixClient::redactEvent(const QString &room_id, const QString &event_id) +{ + QUrlQuery query; + query.addQueryItem("access_token", token_); + + QUrl endpoint(server_); + endpoint.setPath(clientApiUrl_ + QString("/rooms/%1/redact/%2/%3") + .arg(room_id) + .arg(event_id) + .arg(incrementTransactionId())); + endpoint.setQuery(query); + + QNetworkRequest request(QString(endpoint.toEncoded())); + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + + // TODO: no reason specified + QJsonObject body{}; + auto reply = put(request, QJsonDocument(body).toJson(QJsonDocument::Compact)); + + connect(reply, &QNetworkReply::finished, this, [reply, this, room_id, event_id]() { + reply->deleteLater(); + + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + auto data = reply->readAll(); + + if (status == 0 || status >= 400) { + try { + mtx::errors::Error res = nlohmann::json::parse(data); + emit redactionFailed(QString::fromStdString(res.error)); + return; + } catch (const std::exception &) { + } + } + + try { + mtx::responses::EventId res = nlohmann::json::parse(data); + emit redactionCompleted(room_id, event_id); + } catch (const std::exception &e) { + emit redactionFailed(QString::fromStdString(e.what())); + } + }); +} diff --git a/src/timeline/TimelineItem.cc b/src/timeline/TimelineItem.cc index 2f04a1bd..371ced10 100644 --- a/src/timeline/TimelineItem.cc +++ b/src/timeline/TimelineItem.cc @@ -40,29 +40,39 @@ TimelineItem::init() body_ = nullptr; font_.setPixelSize(conf::fontSize); + usernameFont_ = font_; + usernameFont_.setWeight(60); QFontMetrics fm(font_); contextMenu_ = new QMenu(this); showReadReceipts_ = new QAction("Read receipts", this); markAsRead_ = new QAction("Mark as read", this); + redactMsg_ = new QAction("Redact message", this); contextMenu_->addAction(showReadReceipts_); contextMenu_->addAction(markAsRead_); + contextMenu_->addAction(redactMsg_); connect(showReadReceipts_, &QAction::triggered, this, [this]() { if (!event_id_.isEmpty()) ChatPage::instance()->showReadReceipts(event_id_); }); + connect(redactMsg_, &QAction::triggered, this, [this]() { + if (!event_id_.isEmpty()) + ChatPage::instance()->redactEvent(room_id_, event_id_); + }); + connect(markAsRead_, &QAction::triggered, this, [this]() { sendReadReceipt(); }); topLayout_ = new QHBoxLayout(this); mainLayout_ = new QVBoxLayout; messageLayout_ = new QHBoxLayout; + messageLayout_->setContentsMargins(0, 0, 20, 4); + messageLayout_->setSpacing(20); topLayout_->setContentsMargins(conf::timeline::msgMargin, conf::timeline::msgMargin, 0, 0); topLayout_->setSpacing(0); - topLayout_->addLayout(mainLayout_, 1); mainLayout_->setContentsMargins(conf::timeline::headerLeftMargin, 0, 0, 0); @@ -73,7 +83,7 @@ TimelineItem::init() // Setting fixed width for checkmark because systems may have a differing width for a // space and the Unicode checkmark. - checkmark_ = new QLabel(" ", this); + checkmark_ = new QLabel(this); checkmark_->setFont(checkmarkFont); checkmark_->setFixedWidth(QFontMetrics{checkmarkFont}.width(CHECKMARK)); } @@ -106,9 +116,6 @@ TimelineItem::TimelineItem(mtx::events::MessageType ty, body.replace("\n", "
"); generateTimestamp(timestamp); - messageLayout_->setContentsMargins(0, 0, 20, 4); - messageLayout_->setSpacing(20); - if (withSender) { generateBody(displayName, body); setupAvatarLayout(displayName); @@ -240,9 +247,6 @@ TimelineItem::TimelineItem(const mtx::events::RoomEvent"); body = "" + body + ""; - messageLayout_->setContentsMargins(0, 0, 20, 4); - messageLayout_->setSpacing(20); - if (with_sender) { auto displayName = TimelineViewManager::displayName(sender); @@ -289,9 +293,6 @@ TimelineItem::TimelineItem(const mtx::events::RoomEvent emoteMsg.replace(conf::strings::url_regex, conf::strings::url_html); emoteMsg.replace("\n", "
"); - messageLayout_->setContentsMargins(0, 0, 20, 4); - messageLayout_->setSpacing(20); - if (with_sender) { generateBody(displayName, emoteMsg); setupAvatarLayout(displayName); @@ -341,9 +342,6 @@ TimelineItem::TimelineItem(const mtx::events::RoomEvent body.replace(conf::strings::url_regex, conf::strings::url_html); body.replace("\n", "
"); - messageLayout_->setContentsMargins(0, 0, 20, 4); - messageLayout_->setSpacing(20); - if (with_sender) { generateBody(displayName, body); setupAvatarLayout(displayName); @@ -400,25 +398,13 @@ TimelineItem::generateBody(const QString &userid, const QString &body) sender = userid.split(":")[0].split("@")[1]; } - QFont usernameFont = font_; - usernameFont.setWeight(60); - - QFontMetrics fm(usernameFont); + QFontMetrics fm(usernameFont_); userName_ = new QLabel(this); - userName_->setFont(usernameFont); + userName_->setFont(usernameFont_); userName_->setText(fm.elidedText(sender, Qt::ElideRight, 500)); - if (body.isEmpty()) - return; - - body_ = new QLabel(this); - body_->setFont(font_); - body_->setWordWrap(true); - body_->setText(QString("%1").arg(replaceEmoji(body))); - body_->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextBrowserInteraction); - body_->setOpenExternalLinks(true); - body_->setMargin(0); + generateBody(body); } void @@ -474,12 +460,8 @@ TimelineItem::setupAvatarLayout(const QString &userName) if (userName[0] == '@' && userName.size() > 1) userAvatar_->setLetter(QChar(userName[1]).toUpper()); - sideLayout_ = new QVBoxLayout; - sideLayout_->setMargin(0); - sideLayout_->setSpacing(0); - sideLayout_->addWidget(userAvatar_); - sideLayout_->addStretch(1); - topLayout_->insertLayout(0, sideLayout_); + topLayout_->insertWidget(0, userAvatar_); + topLayout_->setAlignment(userAvatar_, Qt::AlignTop); headerLayout_ = new QVBoxLayout; headerLayout_->setMargin(0); @@ -492,8 +474,8 @@ TimelineItem::setupAvatarLayout(const QString &userName) void TimelineItem::setupSimpleLayout() { - topLayout_->setContentsMargins(conf::timeline::avatarSize + conf::timeline::msgMargin + 1, - conf::timeline::msgMargin / 3, + topLayout_->setContentsMargins(conf::timeline::msgMargin + conf::timeline::avatarSize + 2, + conf::timeline::msgMargin, 0, 0); } @@ -533,3 +515,48 @@ TimelineItem::addSaveImageAction(ImageItem *image) connect(saveImage, &QAction::triggered, image, &ImageItem::saveAs); } } + +void +TimelineItem::addAvatar() +{ + if (userAvatar_) + return; + + // TODO: should be replaced with the proper event struct. + auto userid = descriptionMsg_.userid; + auto displayName = TimelineViewManager::displayName(userid); + + QFontMetrics fm(usernameFont_); + userName_ = new QLabel(this); + userName_->setFont(usernameFont_); + userName_->setText(fm.elidedText(displayName, Qt::ElideRight, 500)); + + QWidget *widget = nullptr; + + // Extract the widget before we delete its layout. + if (widgetLayout_) + widget = widgetLayout_->itemAt(0)->widget(); + + // Remove all items from the layout. + QLayoutItem *item; + while ((item = messageLayout_->takeAt(0)) != 0) + delete item; + + setupAvatarLayout(displayName); + + // Restore widget's layout. + if (widget) { + widgetLayout_ = new QHBoxLayout(); + widgetLayout_->setContentsMargins(0, 5, 0, 0); + widgetLayout_->addWidget(widget); + widgetLayout_->addStretch(1); + + headerLayout_->addLayout(widgetLayout_); + } + + messageLayout_->addLayout(headerLayout_, 1); + messageLayout_->addWidget(checkmark_); + messageLayout_->addWidget(timestamp_); + + AvatarProvider::resolve(userid, [this](const QImage &img) { setUserAvatar(img); }); +} diff --git a/src/timeline/TimelineView.cc b/src/timeline/TimelineView.cc index 7e281e03..ded5ad2c 100644 --- a/src/timeline/TimelineView.cc +++ b/src/timeline/TimelineView.cc @@ -491,6 +491,7 @@ TimelineView::updatePendingMessage(int txn_id, QString event_id) if (msg.widget) { msg.widget->setEventId(event_id); msg.widget->markReceived(); + eventIds_[event_id] = msg.widget; } pending_sent_msgs_.append(msg); @@ -591,6 +592,9 @@ TimelineView::isPendingMessage(const QString &txnid, void TimelineView::removePendingMessage(const QString &txnid) { + if (txnid.isEmpty()) + return; + for (auto it = pending_sent_msgs_.begin(); it != pending_sent_msgs_.end(); ++it) { if (QString::number(it->txn_id) == txnid) { int index = std::distance(pending_sent_msgs_.begin(), it); @@ -739,3 +743,44 @@ TimelineView::toggleScrollDownButton() scrollDownBtn_->hide(); } } + +void +TimelineView::removeEvent(const QString &event_id) +{ + if (!eventIds_.contains(event_id)) { + qWarning() << "unknown event_id couldn't be removed:" << event_id; + return; + } + + auto removedItem = eventIds_[event_id]; + + // Find the next and the previous widgets in the timeline + auto prevItem = qobject_cast(relativeWidget(removedItem, -1)); + auto nextItem = qobject_cast(relativeWidget(removedItem, 1)); + + // If it's a TimelineItem add an avatar. + if (prevItem) + prevItem->addAvatar(); + + if (nextItem) + nextItem->addAvatar(); + + // Finally remove the event. + removedItem->deleteLater(); + eventIds_.remove(event_id); +} + +QWidget * +TimelineView::relativeWidget(TimelineItem *item, int dt) const +{ + int pos = scroll_layout_->indexOf(item); + + if (pos == -1) + return nullptr; + + pos = pos + dt; + + bool isOutOfBounds = (pos <= 0 || pos > scroll_layout_->count() - 1); + + return isOutOfBounds ? nullptr : scroll_layout_->itemAt(pos)->widget(); +} diff --git a/src/timeline/TimelineViewManager.cc b/src/timeline/TimelineViewManager.cc index ccbf509b..55f25dfc 100644 --- a/src/timeline/TimelineViewManager.cc +++ b/src/timeline/TimelineViewManager.cc @@ -44,6 +44,16 @@ TimelineViewManager::TimelineViewManager(QSharedPointer client, QW &MatrixClient::messageSendFailed, this, &TimelineViewManager::messageSendFailed); + + connect(client_.data(), + &MatrixClient::redactionCompleted, + this, + [this](const QString &room_id, const QString &event_id) { + auto view = views_[room_id]; + + if (view) + view->removeEvent(event_id); + }); } void