From e4dedbcaba544b8cd9b7fea20ece4dad262b2c34 Mon Sep 17 00:00:00 2001
From: Konstantinos Sideris <sideris.konstantin@gmail.com>
Date: Tue, 17 Jul 2018 23:50:18 +0300
Subject: [PATCH] Mark own read messages with a double checkmark (#377)

---
 resources/icons/ui/double-tick-indicator.png  |   6 +
 .../icons/ui/double-tick-indicator@2x.png     |   9 ++
 resources/res.qrc                             |   2 +
 src/Cache.cpp                                 | 111 ++++++++++++++++++
 src/Cache.h                                   |  20 ++++
 src/ChatPage.cpp                              |   6 +
 src/timeline/TimelineItem.cpp                 |  36 ++++++
 src/timeline/TimelineItem.h                   |   7 ++
 src/timeline/TimelineView.cpp                 |  51 +++++++-
 src/timeline/TimelineView.h                   |   4 +
 src/timeline/TimelineViewManager.cpp          |  11 ++
 src/timeline/TimelineViewManager.h            |   1 +
 12 files changed, 263 insertions(+), 1 deletion(-)
 create mode 100644 resources/icons/ui/double-tick-indicator.png
 create mode 100644 resources/icons/ui/double-tick-indicator@2x.png

diff --git a/resources/icons/ui/double-tick-indicator.png b/resources/icons/ui/double-tick-indicator.png
new file mode 100644
index 000000000..23f45617a
--- /dev/null
+++ b/resources/icons/ui/double-tick-indicator.png
@@ -0,0 +1,6 @@
+‰PNG
+
+���
IHDR��� ��� ���Ùs²���gAMA��±üa��� cHRM��z&��€„��ú���€è��u0��ê`��:˜��pœºQ<���bKGD��ª#2���	pHYs��
��
×B(›x���tIMEâž7¢��IDATHÇíÔ¿NÂP€ñ/jœ\IH‰ƒ‰/ÀÎ3°¹¨«òÇÇ1Æ 1bŒ#«/`œ4°8³8Izz ¹Ð³aÂÙîðûÚÞÛöóßæ0¶òñlü†²‹Õ¯tÈX¸ ¼ãëÕ_„^R~‹ „”•?#?“ñšò
+�6^W~¡¼oã
åU�Òôl¼¹ÂŸlüNù¥ò`=Ïq[Ÿ’àŒ)BÈ�)ºëù1Ÿ¼-%
+Lhiâœ_®•·]7?DEÂgŒ ´5q¢¼å~ö<£X¢ÀAèh<7o+Ñ%
x<lßyW"Àç>ÙÁ¹ãäçîJ^›(RZ:P_úÉÌß«?­¿ëýìäÌ�D‰”Y‹?ŒÒ���%tEXtdate:create�2018-07-17T19:16:04+02:00{i3���%tEXtdate:modify�2018-07-17T19:16:04+02:00
+Jя���tEXtSoftware�www.inkscape.org›î<����IEND®B`‚
\ No newline at end of file
diff --git a/resources/icons/ui/double-tick-indicator@2x.png b/resources/icons/ui/double-tick-indicator@2x.png
new file mode 100644
index 000000000..3e99867a0
--- /dev/null
+++ b/resources/icons/ui/double-tick-indicator@2x.png
@@ -0,0 +1,9 @@
+‰PNG
+
+���
IHDR���@���@����`¹U���gAMA��±üa��� cHRM��z&��€„��ú���€è��u0��ê`��:˜��pœºQ<���bKGD��ª#2���	pHYs��
��
×B(›x���tIMEâ
+yª¥��ŠIDAThÞ혻JA†¿Ò˜xÉz¿…ÄÒ°·×ÇRÚù¶>Žˆ$xCo1ÁvV6‚f,¢$3d6ÎîŒ2·{X¾Ù™s–…›¹Øj‘¬[|™OÊÚê,Ï’w‰o hhJ<!\2î/4
+E¿«‚:%—xà‚T-ðÐVl»Å°$Ug¸—ðUÛ/!?MÝ'~ŠšOü$w>ñÜúďQõ‰åÆ'~„kŸøa®|⇸ð‰8÷‰Ïsæ¿ÜjŠ_a>¦šcœtg7-~Sü*‚W­BŽ
+‚ŠFAÅpœ/´
+M¼Ð(¨ø~Ž’â;+´ð¥ë>Ì·Þšôˆª îfUA–ÝO¶óã¢_*ÈkexðÒ+dÙKwîÓ)ô²›¾í$WÈ°c§ë%SÈ°e¯éš+ô°i·ç›*lØ9f
+N&^2«×\Áú¼—^”s¹þܐÔ‰§*8Á·:áÛœá›
+:ü‚S<tûչƇ„„„„„üA¾�…2RÅ
)‡D���%tEXtdate:create�2018-07-17T19:16:10+02:00CòM¾���%tEXtdate:modify�2018-07-17T19:16:10+02:002¯õ���tEXtSoftware�www.inkscape.org›î<����IEND®B`‚
\ No newline at end of file
diff --git a/resources/res.qrc b/resources/res.qrc
index fe3e31040..71463e651 100644
--- a/resources/res.qrc
+++ b/resources/res.qrc
@@ -1,5 +1,7 @@
 <RCC>
     <qresource prefix="/icons">
+        <file>icons/ui/double-tick-indicator.png</file>
+        <file>icons/ui/double-tick-indicator@2x.png</file>
         <file>icons/ui/lock.png</file>
         <file>icons/ui/lock@2x.png</file>
         <file>icons/ui/clock.png</file>
diff --git a/src/Cache.cpp b/src/Cache.cpp
index 6f71b746d..452567c30 100644
--- a/src/Cache.cpp
+++ b/src/Cache.cpp
@@ -649,6 +649,70 @@ Cache::setCurrentFormat()
         txn.commit();
 }
 
+std::vector<QString>
+Cache::pendingReceiptsEvents(lmdb::txn &txn, const std::string &room_id)
+{
+        auto db = getPendingReceiptsDb(txn);
+
+        std::string key, unused;
+        std::vector<QString> pending;
+
+        auto cursor = lmdb::cursor::open(txn, db);
+        while (cursor.get(key, unused, MDB_NEXT)) {
+                ReadReceiptKey receipt;
+                try {
+                        receipt = json::parse(key);
+                } catch (const nlohmann::json::exception &e) {
+                        nhlog::db()->warn("pendingReceiptsEvents: {}", e.what());
+                        continue;
+                }
+
+                if (receipt.room_id == room_id)
+                        pending.emplace_back(QString::fromStdString(receipt.event_id));
+        }
+
+        cursor.close();
+
+        return pending;
+}
+
+void
+Cache::removePendingReceipt(lmdb::txn &txn, const std::string &room_id, const std::string &event_id)
+{
+        auto db = getPendingReceiptsDb(txn);
+
+        ReadReceiptKey receipt_key{event_id, room_id};
+        auto key = json(receipt_key).dump();
+
+        try {
+                lmdb::dbi_del(txn, db, lmdb::val(key.data(), key.size()), nullptr);
+        } catch (const lmdb::error &e) {
+                nhlog::db()->critical("removePendingReceipt: {}", e.what());
+        }
+}
+
+void
+Cache::addPendingReceipt(const QString &room_id, const QString &event_id)
+{
+        auto txn = lmdb::txn::begin(env_);
+        auto db  = getPendingReceiptsDb(txn);
+
+        ReadReceiptKey receipt_key{event_id.toStdString(), room_id.toStdString()};
+        auto key = json(receipt_key).dump();
+        std::string empty;
+
+        try {
+                lmdb::dbi_put(txn,
+                              db,
+                              lmdb::val(key.data(), key.size()),
+                              lmdb::val(empty.data(), empty.size()));
+        } catch (const lmdb::error &e) {
+                nhlog::db()->critical("addPendingReceipt: {}", e.what());
+        }
+
+        txn.commit();
+}
+
 CachedReceipts
 Cache::readReceipts(const QString &event_id, const QString &room_id)
 {
@@ -684,6 +748,30 @@ Cache::readReceipts(const QString &event_id, const QString &room_id)
         return receipts;
 }
 
+std::vector<QString>
+Cache::filterReadEvents(const QString &room_id,
+                        const std::vector<QString> &event_ids,
+                        const std::string &excluded_user)
+{
+        std::vector<QString> read_events;
+
+        for (const auto &event : event_ids) {
+                auto receipts = readReceipts(event, room_id);
+
+                if (receipts.size() == 0)
+                        continue;
+
+                if (receipts.size() == 1) {
+                        if (receipts.begin()->second == excluded_user)
+                                continue;
+                }
+
+                read_events.emplace_back(event);
+        }
+
+        return read_events;
+}
+
 void
 Cache::updateReadReceipt(lmdb::txn &txn, const std::string &room_id, const Receipts &receipts)
 {
@@ -733,6 +821,23 @@ Cache::updateReadReceipt(lmdb::txn &txn, const std::string &room_id, const Recei
         }
 }
 
+void
+Cache::notifyForReadReceipts(lmdb::txn &txn, const std::string &room_id)
+{
+        QSettings settings;
+        auto local_user = settings.value("auth/user_id").toString();
+
+        auto matches = filterReadEvents(QString::fromStdString(room_id),
+                                        pendingReceiptsEvents(txn, room_id),
+                                        local_user.toStdString());
+
+        for (const auto &m : matches)
+                removePendingReceipt(txn, room_id, m.toStdString());
+
+        if (!matches.empty())
+                emit newReadReceipts(QString::fromStdString(room_id), matches);
+}
+
 void
 Cache::saveState(const mtx::responses::Sync &res)
 {
@@ -771,6 +876,12 @@ Cache::saveState(const mtx::responses::Sync &res)
         removeLeftRooms(txn, res.rooms.leave);
 
         txn.commit();
+
+        for (const auto &room : res.rooms.join) {
+                auto txn = lmdb::txn::begin(env_);
+                notifyForReadReceipts(txn, room.first);
+                txn.commit();
+        }
 }
 
 void
diff --git a/src/Cache.h b/src/Cache.h
index fa8355a58..d5d1729e0 100644
--- a/src/Cache.h
+++ b/src/Cache.h
@@ -347,6 +347,18 @@ public:
         using UserReceipts = std::multimap<uint64_t, std::string, std::greater<uint64_t>>;
         UserReceipts readReceipts(const QString &event_id, const QString &room_id);
 
+        //! Filter the events that have at least one read receipt.
+        std::vector<QString> filterReadEvents(const QString &room_id,
+                                              const std::vector<QString> &event_ids,
+                                              const std::string &excluded_user);
+        //! Add event for which we are expecting some read receipts.
+        void addPendingReceipt(const QString &room_id, const QString &event_id);
+        void removePendingReceipt(lmdb::txn &txn,
+                                  const std::string &room_id,
+                                  const std::string &event_id);
+        void notifyForReadReceipts(lmdb::txn &txn, const std::string &room_id);
+        std::vector<QString> pendingReceiptsEvents(lmdb::txn &txn, const std::string &room_id);
+
         QByteArray image(const QString &url) const;
         QByteArray image(lmdb::txn &txn, const std::string &url) const;
         QByteArray image(const std::string &url) const
@@ -421,6 +433,9 @@ public:
 
         OlmSessionStorage session_storage;
 
+signals:
+        void newReadReceipts(const QString &room_id, const std::vector<QString> &event_ids);
+
 private:
         //! Save an invited room.
         void saveInvite(lmdb::txn &txn,
@@ -582,6 +597,11 @@ private:
                 }
         }
 
+        lmdb::dbi getPendingReceiptsDb(lmdb::txn &txn)
+        {
+                return lmdb::dbi::open(txn, "pending_receipts", MDB_CREATE);
+        }
+
         lmdb::dbi getMessagesDb(lmdb::txn &txn, const std::string &room_id)
         {
                 auto db =
diff --git a/src/ChatPage.cpp b/src/ChatPage.cpp
index cc7a57414..6f5e31e56 100644
--- a/src/ChatPage.cpp
+++ b/src/ChatPage.cpp
@@ -685,6 +685,11 @@ ChatPage::bootstrap(QString userid, QString homeserver, QString token)
         try {
                 cache::init(userid);
 
+                connect(cache::client(),
+                        &Cache::newReadReceipts,
+                        view_manager_,
+                        &TimelineViewManager::updateReadReceipts);
+
                 const bool isInitialized = cache::client()->isInitialized();
                 const bool isValid       = cache::client()->isFormatValid();
 
@@ -700,6 +705,7 @@ ChatPage::bootstrap(QString userid, QString homeserver, QString token)
                         loadStateFromCache();
                         return;
                 }
+
         } catch (const lmdb::error &e) {
                 nhlog::db()->critical("failure during boot: {}", e.what());
                 cache::client()->deleteData();
diff --git a/src/timeline/TimelineItem.cpp b/src/timeline/TimelineItem.cpp
index 88ab19634..696db8def 100644
--- a/src/timeline/TimelineItem.cpp
+++ b/src/timeline/TimelineItem.cpp
@@ -42,6 +42,7 @@ StatusIndicator::StatusIndicator(QWidget *parent)
         lockIcon_.addFile(":/icons/icons/ui/lock.png");
         clockIcon_.addFile(":/icons/icons/ui/clock.png");
         checkmarkIcon_.addFile(":/icons/icons/ui/checkmark.png");
+        doubleCheckmarkIcon_.addFile(":/icons/icons/ui/double-tick-indicator.png");
 }
 
 void
@@ -79,6 +80,10 @@ StatusIndicator::paintEvent(QPaintEvent *)
                 paintIcon(p, checkmarkIcon_);
                 break;
         }
+        case StatusIndicatorState::Read: {
+                paintIcon(p, doubleCheckmarkIcon_);
+                break;
+        }
         case StatusIndicatorState::Empty:
                 break;
         }
@@ -302,6 +307,8 @@ TimelineItem::TimelineItem(ImageItem *image,
         setupWidgetLayout<mtx::events::RoomEvent<mtx::events::msg::Image>, ImageItem>(
           image, event, with_sender);
 
+        markOwnMessagesAsReceived(event.sender);
+
         addSaveImageAction(image);
 }
 
@@ -315,6 +322,8 @@ TimelineItem::TimelineItem(StickerItem *image,
 {
         setupWidgetLayout<mtx::events::Sticker, StickerItem>(image, event, with_sender);
 
+        markOwnMessagesAsReceived(event.sender);
+
         addSaveImageAction(image);
 }
 
@@ -328,6 +337,8 @@ TimelineItem::TimelineItem(FileItem *file,
 {
         setupWidgetLayout<mtx::events::RoomEvent<mtx::events::msg::File>, FileItem>(
           file, event, with_sender);
+
+        markOwnMessagesAsReceived(event.sender);
 }
 
 TimelineItem::TimelineItem(AudioItem *audio,
@@ -340,6 +351,8 @@ TimelineItem::TimelineItem(AudioItem *audio,
 {
         setupWidgetLayout<mtx::events::RoomEvent<mtx::events::msg::Audio>, AudioItem>(
           audio, event, with_sender);
+
+        markOwnMessagesAsReceived(event.sender);
 }
 
 TimelineItem::TimelineItem(VideoItem *video,
@@ -352,6 +365,8 @@ TimelineItem::TimelineItem(VideoItem *video,
 {
         setupWidgetLayout<mtx::events::RoomEvent<mtx::events::msg::Video>, VideoItem>(
           video, event, with_sender);
+
+        markOwnMessagesAsReceived(event.sender);
 }
 
 /*
@@ -367,6 +382,8 @@ TimelineItem::TimelineItem(const mtx::events::RoomEvent<mtx::events::msg::Notice
         init();
         addReplyAction();
 
+        markOwnMessagesAsReceived(event.sender);
+
         event_id_            = QString::fromStdString(event.event_id);
         const auto sender    = QString::fromStdString(event.sender);
         const auto timestamp = QDateTime::fromMSecsSinceEpoch(event.origin_server_ts);
@@ -413,6 +430,8 @@ TimelineItem::TimelineItem(const mtx::events::RoomEvent<mtx::events::msg::Emote>
         init();
         addReplyAction();
 
+        markOwnMessagesAsReceived(event.sender);
+
         event_id_         = QString::fromStdString(event.event_id);
         const auto sender = QString::fromStdString(event.sender);
 
@@ -455,6 +474,8 @@ TimelineItem::TimelineItem(const mtx::events::RoomEvent<mtx::events::msg::Text>
         init();
         addReplyAction();
 
+        markOwnMessagesAsReceived(event.sender);
+
         event_id_         = QString::fromStdString(event.event_id);
         const auto sender = QString::fromStdString(event.sender);
 
@@ -495,6 +516,21 @@ TimelineItem::markSent()
         statusIndicator_->setState(StatusIndicatorState::Sent);
 }
 
+void
+TimelineItem::markOwnMessagesAsReceived(const std::string &sender)
+{
+        QSettings settings;
+        if (sender == settings.value("auth/user_id").toString().toStdString())
+                statusIndicator_->setState(StatusIndicatorState::Received);
+}
+
+void
+TimelineItem::markRead()
+{
+        if (statusIndicator_->state() != StatusIndicatorState::Encrypted)
+                statusIndicator_->setState(StatusIndicatorState::Read);
+}
+
 void
 TimelineItem::markReceived(bool isEncrypted)
 {
diff --git a/src/timeline/TimelineItem.h b/src/timeline/TimelineItem.h
index d3cab0a06..874c00df1 100644
--- a/src/timeline/TimelineItem.h
+++ b/src/timeline/TimelineItem.h
@@ -50,6 +50,8 @@ enum class StatusIndicatorState
         Encrypted,
         //! The plaintext message was received by the server.
         Received,
+        //! At least one of the participants has read the message.
+        Read,
         //! The client sent the message. Not yet received.
         Sent,
         //! When the message is loaded from cache or backfill.
@@ -66,6 +68,7 @@ class StatusIndicator : public QWidget
 public:
         explicit StatusIndicator(QWidget *parent);
         void setState(StatusIndicatorState state);
+        StatusIndicatorState state() const { return state_; }
 
 protected:
         void paintEvent(QPaintEvent *event) override;
@@ -76,6 +79,7 @@ private:
         QIcon lockIcon_;
         QIcon clockIcon_;
         QIcon checkmarkIcon_;
+        QIcon doubleCheckmarkIcon_;
 
         QColor iconColor_ = QColor("#999");
 
@@ -234,6 +238,7 @@ public:
         QString eventId() const { return event_id_; }
         void setEventId(const QString &event_id) { event_id_ = event_id; }
         void markReceived(bool isEncrypted);
+        void markRead();
         void markSent();
         bool isReceived() { return isReceived_; };
         void setRoomId(QString room_id) { room_id_ = room_id; }
@@ -252,6 +257,8 @@ protected:
         void contextMenuEvent(QContextMenuEvent *event) override;
 
 private:
+        //! If we are the sender of the message the event wil be marked as received by the server.
+        void markOwnMessagesAsReceived(const std::string &sender);
         void init();
         //! Add a context menu option to save the image of the timeline item.
         void addSaveImageAction(ImageItem *image);
diff --git a/src/timeline/TimelineView.cpp b/src/timeline/TimelineView.cpp
index a8c048070..074ba4980 100644
--- a/src/timeline/TimelineView.cpp
+++ b/src/timeline/TimelineView.cpp
@@ -18,6 +18,7 @@
 #include <QApplication>
 #include <QFileInfo>
 #include <QTimer>
+#include <QtConcurrent>
 
 #include "Cache.h"
 #include "ChatPage.h"
@@ -352,6 +353,27 @@ TimelineView::parseEncryptedEvent(const mtx::events::EncryptedEvent<mtx::events:
         return {dummy, false};
 }
 
+void
+TimelineView::displayReadReceipts(std::vector<TimelineEvent> events)
+{
+        QtConcurrent::run(
+          [events = std::move(events), room_id = room_id_, local_user = local_user_, this]() {
+                  std::vector<QString> event_ids;
+
+                  for (const auto &e : events) {
+                          if (utils::event_sender(e) == local_user)
+                                  event_ids.emplace_back(
+                                    QString::fromStdString(utils::event_id(e)));
+                  }
+
+                  auto readEvents =
+                    cache::client()->filterReadEvents(room_id, event_ids, local_user.toStdString());
+
+                  if (!readEvents.empty())
+                          emit markReadEvents(readEvents);
+          });
+}
+
 void
 TimelineView::renderBottomEvents(const std::vector<TimelineEvent> &events)
 {
@@ -373,6 +395,8 @@ TimelineView::renderBottomEvents(const std::vector<TimelineEvent> &events)
 
         lastMessageDirection_ = TimelineDirection::Bottom;
 
+        displayReadReceipts(events);
+
         QApplication::processEvents();
 }
 
@@ -407,6 +431,8 @@ TimelineView::renderTopEvents(const std::vector<TimelineEvent> &events)
 
         QApplication::processEvents();
 
+        displayReadReceipts(events);
+
         // If this batch is the first being rendered (i.e the first and the last
         // events originate from this batch), set the last sender.
         if (lastSender_.isEmpty() && !items.empty()) {
@@ -499,6 +525,23 @@ TimelineView::init()
         connect(this, &TimelineView::messageFailed, this, &TimelineView::handleFailedMessage);
         connect(this, &TimelineView::messageSent, this, &TimelineView::updatePendingMessage);
 
+        connect(
+          this, &TimelineView::markReadEvents, this, [this](const std::vector<QString> &event_ids) {
+                  for (const auto &event : event_ids) {
+                          if (eventIds_.contains(event)) {
+                                  auto widget = eventIds_[event];
+                                  if (!widget)
+                                          return;
+
+                                  auto item = qobject_cast<TimelineItem *>(widget);
+                                  if (!item)
+                                          return;
+
+                                  item->markRead();
+                          }
+                  }
+          });
+
         connect(scroll_area_->verticalScrollBar(),
                 SIGNAL(valueChanged(int)),
                 this,
@@ -615,6 +658,7 @@ TimelineView::updatePendingMessage(const std::string &txn_id, const QString &eve
                         // we've already marked the widget as received.
                         if (!msg.widget->isReceived()) {
                                 msg.widget->markReceived(msg.is_encrypted);
+                                cache::client()->addPendingReceipt(room_id_, event_id);
                                 pending_sent_msgs_.append(msg);
                         }
                 } else {
@@ -826,9 +870,14 @@ TimelineView::removePendingMessage(const std::string &txn_id)
         }
         for (auto it = pending_msgs_.begin(); it != pending_msgs_.end(); ++it) {
                 if (it->txn_id == txn_id) {
-                        if (it->widget)
+                        if (it->widget) {
                                 it->widget->markReceived(it->is_encrypted);
 
+                                // TODO: update when a solution for encrypted messages is available.
+                                if (!it->is_encrypted)
+                                        cache::client()->addPendingReceipt(room_id_, it->event_id);
+                        }
+
                         nhlog::ui()->info("[{}] received sync before message response", txn_id);
                         return;
                 }
diff --git a/src/timeline/TimelineView.h b/src/timeline/TimelineView.h
index 7b269063a..5c42415a3 100644
--- a/src/timeline/TimelineView.h
+++ b/src/timeline/TimelineView.h
@@ -156,6 +156,7 @@ signals:
         void messagesRetrieved(const mtx::responses::Messages &res);
         void messageFailed(const std::string &txn_id);
         void messageSent(const std::string &txn_id, const QString &event_id);
+        void markReadEvents(const std::vector<QString> &event_ids);
 
 protected:
         void paintEvent(QPaintEvent *event) override;
@@ -165,6 +166,9 @@ protected:
 private:
         using TimelineEvent = mtx::events::collections::TimelineEvents;
 
+        //! Mark our own widgets as read if they have more than one receipt.
+        void displayReadReceipts(std::vector<TimelineEvent> events);
+
         QWidget *relativeWidget(QWidget *item, int dt) const;
 
         DecryptionResult parseEncryptedEvent(
diff --git a/src/timeline/TimelineViewManager.cpp b/src/timeline/TimelineViewManager.cpp
index 1decab35e..1bbb4defa 100644
--- a/src/timeline/TimelineViewManager.cpp
+++ b/src/timeline/TimelineViewManager.cpp
@@ -36,6 +36,17 @@ TimelineViewManager::TimelineViewManager(QWidget *parent)
         setStyleSheet("border: none;");
 }
 
+void
+TimelineViewManager::updateReadReceipts(const QString &room_id,
+                                        const std::vector<QString> &event_ids)
+{
+        if (timelineViewExists(room_id)) {
+                auto view = views_[room_id];
+                if (view)
+                        emit view->markReadEvents(event_ids);
+        }
+}
+
 void
 TimelineViewManager::removeTimelineEvent(const QString &room_id, const QString &event_id)
 {
diff --git a/src/timeline/TimelineViewManager.h b/src/timeline/TimelineViewManager.h
index f3c099c18..d23345d34 100644
--- a/src/timeline/TimelineViewManager.h
+++ b/src/timeline/TimelineViewManager.h
@@ -57,6 +57,7 @@ signals:
         void updateRoomsLastMessage(const QString &user, const DescInfo &info);
 
 public slots:
+        void updateReadReceipts(const QString &room_id, const std::vector<QString> &event_ids);
         void removeTimelineEvent(const QString &room_id, const QString &event_id);
         void initWithMessages(const std::map<QString, mtx::responses::Timeline> &msgs);
 
-- 
GitLab