summary refs log tree commit diff
diff options
context:
space:
mode:
authorKonstantinos Sideris <sideris.konstantin@gmail.com>2018-01-03 18:05:49 +0200
committerKonstantinos Sideris <sideris.konstantin@gmail.com>2018-01-03 18:06:29 +0200
commiteaf05748ff1fc2b1ced8fdb329661ff20d6b7b85 (patch)
tree4d2f190f9662581f7ff8a1e95b146ba225e0a8a6
parentAdd Alpine Linux installation instructions (#191) (diff)
downloadnheko-eaf05748ff1fc2b1ced8fdb329661ff20d6b7b85.tar.xz
Initial support for read receipts
-rw-r--r--CMakeLists.txt2
-rw-r--r--include/AvatarProvider.h4
-rw-r--r--include/Cache.h55
-rw-r--r--include/ChatPage.h12
-rw-r--r--include/Config.h4
-rw-r--r--include/MainWindow.h2
-rw-r--r--include/dialogs/ReadReceipts.h50
-rw-r--r--include/timeline/TimelineItem.h8
-rw-r--r--include/ui/OverlayModal.h2
m---------libs/matrix-structs0
-rw-r--r--resources/styles/nheko-dark.qss1
-rw-r--r--resources/styles/nheko.qss1
-rw-r--r--src/AvatarProvider.cc22
-rw-r--r--src/Cache.cc103
-rw-r--r--src/ChatPage.cc32
-rw-r--r--src/MainWindow.cc6
-rw-r--r--src/dialogs/ReadReceipts.cc124
-rw-r--r--src/timeline/TimelineItem.cc26
-rw-r--r--src/ui/OverlayModal.cc11
19 files changed, 433 insertions, 32 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 3085cc76..c669262b 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -146,6 +146,7 @@ set(SRC_FILES
     src/dialogs/JoinRoom.cc
     src/dialogs/LeaveRoom.cc
     src/dialogs/Logout.cc
+    src/dialogs/ReadReceipts.cc
 
     # Emoji
     src/emoji/Category.cc
@@ -226,6 +227,7 @@ qt5_wrap_cpp(MOC_HEADERS
     include/dialogs/JoinRoom.h
     include/dialogs/LeaveRoom.h
     include/dialogs/Logout.h
+    include/dialogs/ReadReceipts.h
 
     # Emoji
     include/emoji/Category.h
diff --git a/include/AvatarProvider.h b/include/AvatarProvider.h
index 906f2593..44bf1ad2 100644
--- a/include/AvatarProvider.h
+++ b/include/AvatarProvider.h
@@ -36,7 +36,7 @@ class AvatarProvider : public QObject
 
 public:
         static void init(QSharedPointer<MatrixClient> client);
-        static void resolve(const QString &userId, TimelineItem *item);
+        static void resolve(const QString &userId, std::function<void(QImage)> callback);
         static void setAvatarUrl(const QString &userId, const QUrl &url);
 
         static void clear();
@@ -48,5 +48,5 @@ private:
 
         using UserID = QString;
         static QMap<UserID, AvatarData> avatars_;
-        static QMap<UserID, QList<TimelineItem *>> toBeResolved_;
+        static QMap<UserID, QList<std::function<void(QImage)>>> toBeResolved_;
 };
diff --git a/include/Cache.h b/include/Cache.h
index 1f6c59f0..ae58e418 100644
--- a/include/Cache.h
+++ b/include/Cache.h
@@ -18,11 +18,52 @@
 #pragma once
 
 #include <QDir>
+#include <json.hpp>
 #include <lmdb++.h>
 #include <mtx/responses.hpp>
 
 class RoomState;
 
+//! Used to uniquely identify a list of read receipts.
+struct ReadReceiptKey
+{
+        std::string event_id;
+        std::string room_id;
+};
+
+inline void
+to_json(json &j, const ReadReceiptKey &key)
+{
+        j = json{{"event_id", key.event_id}, {"room_id", key.room_id}};
+}
+
+inline void
+from_json(const json &j, ReadReceiptKey &key)
+{
+        key.event_id = j.at("event_id").get<std::string>();
+        key.room_id  = j.at("room_id").get<std::string>();
+}
+
+//! Decribes a read receipt stored in cache.
+struct ReadReceiptValue
+{
+        std::string user_id;
+        uint64_t ts;
+};
+
+inline void
+to_json(json &j, const ReadReceiptValue &value)
+{
+        j = json{{"user_id", value.user_id}, {"ts", value.ts}};
+}
+
+inline void
+from_json(const json &j, ReadReceiptValue &value)
+{
+        value.user_id = j.at("user_id").get<std::string>();
+        value.ts      = j.at("ts").get<uint64_t>();
+}
+
 class Cache
 {
 public:
@@ -48,6 +89,19 @@ public:
         bool isFormatValid();
         void setCurrentFormat();
 
+        //! Adds a user to the read list for the given event.
+        //!
+        //! There should be only one user id present in a receipt list per room.
+        //! The user id should be removed from any other lists.
+        using Receipts = std::map<std::string, std::map<std::string, uint64_t>>;
+        void updateReadReceipt(const std::string &room_id, const Receipts &receipts);
+
+        //! Retrieve all the read receipts for the given event id and room.
+        //!
+        //! Returns a map of user ids and the time of the read receipt in milliseconds.
+        using UserReceipts = std::multimap<uint64_t, std::string>;
+        UserReceipts readReceipts(const QString &event_id, const QString &room_id);
+
         QByteArray image(const QString &url) const;
         void saveImage(const QString &url, const QByteArray &data);
 
@@ -60,6 +114,7 @@ private:
         lmdb::dbi roomDb_;
         lmdb::dbi invitesDb_;
         lmdb::dbi imagesDb_;
+        lmdb::dbi readReceiptsDb_;
 
         bool isMounted_;
 
diff --git a/include/ChatPage.h b/include/ChatPage.h
index 24fc6a25..584424c0 100644
--- a/include/ChatPage.h
+++ b/include/ChatPage.h
@@ -42,6 +42,10 @@ class TypingDisplay;
 class UserInfoWidget;
 class UserSettings;
 
+namespace dialogs {
+class ReadReceipts;
+}
+
 constexpr int CONSENSUS_TIMEOUT      = 1000;
 constexpr int SHOW_CONTENT_TIMEOUT   = 3000;
 constexpr int TYPING_REFRESH_TIMEOUT = 10000;
@@ -59,6 +63,9 @@ public:
         // Initialize all the components of the UI.
         void bootstrap(QString userid, QString homeserver, QString token);
         void showQuickSwitcher();
+        void showReadReceipts(const QString &event_id);
+
+        static ChatPage *instance() { return instance_; }
 
 signals:
         void contentLoaded();
@@ -84,6 +91,8 @@ private slots:
         void removeInvite(const QString &room_id);
 
 private:
+        static ChatPage *instance_;
+
         using UserID      = QString;
         using RoomStates  = QMap<UserID, RoomState>;
         using Membership  = mtx::events::StateEvent<mtx::events::state::Member>;
@@ -150,6 +159,9 @@ private:
         QSharedPointer<QuickSwitcher> quickSwitcher_;
         QSharedPointer<OverlayModal> quickSwitcherModal_;
 
+        QSharedPointer<dialogs::ReadReceipts> receiptsDialog_;
+        QSharedPointer<OverlayModal> receiptsModal_;
+
         // Matrix Client API provider.
         QSharedPointer<MatrixClient> client_;
 
diff --git a/include/Config.h b/include/Config.h
index 7d35094e..5492e5fb 100644
--- a/include/Config.h
+++ b/include/Config.h
@@ -15,6 +15,10 @@ static constexpr int emojiSize                  = 14;
 static constexpr int headerFontSize             = 21;
 static constexpr int typingNotificationFontSize = 11;
 
+namespace receipts {
+static constexpr int font = 12;
+}
+
 namespace dialogs {
 static constexpr int labelSize = 15;
 }
diff --git a/include/MainWindow.h b/include/MainWindow.h
index 2d047b51..d7c5e41d 100644
--- a/include/MainWindow.h
+++ b/include/MainWindow.h
@@ -42,7 +42,7 @@ public:
         explicit MainWindow(QWidget *parent = 0);
         ~MainWindow();
 
-        static MainWindow *instance();
+        static MainWindow *instance() { return instance_; };
         void saveCurrentWindowSize();
 
 protected:
diff --git a/include/dialogs/ReadReceipts.h b/include/dialogs/ReadReceipts.h
new file mode 100644
index 00000000..42a9e1b7
--- /dev/null
+++ b/include/dialogs/ReadReceipts.h
@@ -0,0 +1,50 @@
+#pragma once
+
+#include <QDateTime>
+#include <QFrame>
+#include <QHBoxLayout>
+#include <QLabel>
+#include <QListWidget>
+#include <QVBoxLayout>
+
+class Avatar;
+
+namespace dialogs {
+
+class ReceiptItem : public QWidget
+{
+        Q_OBJECT
+
+public:
+        ReceiptItem(QWidget *parent, const QString &user_id, uint64_t timestamp);
+
+private:
+        QString dateFormat(const QDateTime &then) const;
+
+        QHBoxLayout *topLayout_;
+        QVBoxLayout *textLayout_;
+
+        Avatar *avatar_;
+
+        QLabel *userName_;
+        QLabel *timestamp_;
+};
+
+class ReadReceipts : public QFrame
+{
+        Q_OBJECT
+public:
+        explicit ReadReceipts(QWidget *parent = nullptr);
+
+public slots:
+        void addUsers(const std::multimap<uint64_t, std::string> &users);
+
+protected:
+        void paintEvent(QPaintEvent *event) override;
+
+private:
+        QLabel *topLabel_;
+
+        QListWidget *userList_;
+};
+} // dialogs
diff --git a/include/timeline/TimelineItem.h b/include/timeline/TimelineItem.h
index f1498d1b..78fb95c9 100644
--- a/include/timeline/TimelineItem.h
+++ b/include/timeline/TimelineItem.h
@@ -87,6 +87,7 @@ public:
 
 protected:
         void paintEvent(QPaintEvent *event) override;
+        void contextMenuEvent(QContextMenuEvent *event) override;
 
 private:
         void init();
@@ -116,6 +117,9 @@ private:
 
         DescInfo descriptionMsg_;
 
+        QMenu *receiptsMenu_;
+        QAction *showReadReceipts_;
+
         QHBoxLayout *topLayout_;
         QVBoxLayout *sideLayout_; // Avatar or Timestamp
         QVBoxLayout *mainLayout_; // Header & Message body
@@ -156,7 +160,7 @@ TimelineItem::setupLocalWidgetLayout(Widget *widget,
                 setupAvatarLayout(displayName);
                 mainLayout_->addLayout(headerLayout_);
 
-                AvatarProvider::resolve(userid, this);
+                AvatarProvider::resolve(userid, [=](const QImage &img) { setUserAvatar(img); });
         } else {
                 setupSimpleLayout();
         }
@@ -199,7 +203,7 @@ TimelineItem::setupWidgetLayout(Widget *widget,
 
                 mainLayout_->addLayout(headerLayout_);
 
-                AvatarProvider::resolve(sender, this);
+                AvatarProvider::resolve(sender, [=](const QImage &img) { setUserAvatar(img); });
         } else {
                 setupSimpleLayout();
         }
diff --git a/include/ui/OverlayModal.h b/include/ui/OverlayModal.h
index 167ba71a..5f6b6eee 100644
--- a/include/ui/OverlayModal.h
+++ b/include/ui/OverlayModal.h
@@ -18,6 +18,7 @@
 #pragma once
 
 #include <QGraphicsOpacityEffect>
+#include <QKeyEvent>
 #include <QPaintEvent>
 #include <QPropertyAnimation>
 
@@ -37,6 +38,7 @@ public:
 
 protected:
         void paintEvent(QPaintEvent *event) override;
+        void keyPressEvent(QKeyEvent *event) override;
 
 private:
         int duration_;
diff --git a/libs/matrix-structs b/libs/matrix-structs
-Subproject acb732474665343174209f0518c33a7ca0eb504
+Subproject 3555ec1cfc3865e0ae47c0fa53c9ea00f1e7cb3
diff --git a/resources/styles/nheko-dark.qss b/resources/styles/nheko-dark.qss
index 970a1d03..a78fb612 100644
--- a/resources/styles/nheko-dark.qss
+++ b/resources/styles/nheko-dark.qss
@@ -84,6 +84,7 @@ dialogs--Logout,
 dialogs--LeaveRoom,
 dialogs--CreateRoom,
 dialogs--InviteUsers,
+dialogs--ReadReceipts,
 dialogs--JoinRoom {
     background-color: #383c4a;
     color: #caccd1;
diff --git a/resources/styles/nheko.qss b/resources/styles/nheko.qss
index 6bdb6aa6..ce86e212 100644
--- a/resources/styles/nheko.qss
+++ b/resources/styles/nheko.qss
@@ -86,6 +86,7 @@ dialogs--Logout,
 dialogs--LeaveRoom,
 dialogs--CreateRoom,
 dialogs--InviteUsers,
+dialogs--ReadReceipts,
 dialogs--JoinRoom {
     background-color: white;
     color: #333;
diff --git a/src/AvatarProvider.cc b/src/AvatarProvider.cc
index 334f72c3..b8ea9e20 100644
--- a/src/AvatarProvider.cc
+++ b/src/AvatarProvider.cc
@@ -18,12 +18,10 @@
 #include "AvatarProvider.h"
 #include "MatrixClient.h"
 
-#include "timeline/TimelineItem.h"
-
 QSharedPointer<MatrixClient> AvatarProvider::client_;
 
 QMap<QString, AvatarData> AvatarProvider::avatars_;
-QMap<QString, QList<TimelineItem *>> AvatarProvider::toBeResolved_;
+QMap<QString, QList<std::function<void(QImage)>>> AvatarProvider::toBeResolved_;
 
 void
 AvatarProvider::init(QSharedPointer<MatrixClient> client)
@@ -37,11 +35,11 @@ void
 AvatarProvider::updateAvatar(const QString &uid, const QImage &img)
 {
         if (toBeResolved_.contains(uid)) {
-                auto items = toBeResolved_[uid];
+                auto callbacks = toBeResolved_[uid];
 
                 // Update all the timeline items with the resolved avatar.
-                for (const auto item : items)
-                        item->setUserAvatar(img);
+                for (const auto callback : callbacks)
+                        callback(img);
 
                 toBeResolved_.remove(uid);
         }
@@ -53,7 +51,7 @@ AvatarProvider::updateAvatar(const QString &uid, const QImage &img)
 }
 
 void
-AvatarProvider::resolve(const QString &userId, TimelineItem *item)
+AvatarProvider::resolve(const QString &userId, std::function<void(QImage)> callback)
 {
         if (!avatars_.contains(userId))
                 return;
@@ -61,7 +59,7 @@ AvatarProvider::resolve(const QString &userId, TimelineItem *item)
         auto img = avatars_[userId].img;
 
         if (!img.isNull()) {
-                item->setUserAvatar(img);
+                callback(img);
                 return;
         }
 
@@ -69,12 +67,12 @@ AvatarProvider::resolve(const QString &userId, TimelineItem *item)
         if (!toBeResolved_.contains(userId)) {
                 client_->fetchUserAvatar(userId, avatars_[userId].url);
 
-                QList<TimelineItem *> timelineItems;
-                timelineItems.push_back(item);
+                QList<std::function<void(QImage)>> items;
+                items.push_back(callback);
 
-                toBeResolved_.insert(userId, timelineItems);
+                toBeResolved_.insert(userId, items);
         } else {
-                toBeResolved_[userId].push_back(item);
+                toBeResolved_[userId].push_back(callback);
         }
 }
 
diff --git a/src/Cache.cc b/src/Cache.cc
index 06e45f13..e4e700b2 100644
--- a/src/Cache.cc
+++ b/src/Cache.cc
@@ -36,6 +36,7 @@ Cache::Cache(const QString &userId)
   , roomDb_{0}
   , invitesDb_{0}
   , imagesDb_{0}
+  , readReceiptsDb_{0}
   , isMounted_{false}
   , userId_{userId}
 {}
@@ -89,11 +90,12 @@ Cache::setup()
                 env_.open(statePath.toStdString().c_str());
         }
 
-        auto txn   = lmdb::txn::begin(env_);
-        stateDb_   = lmdb::dbi::open(txn, "state", MDB_CREATE);
-        roomDb_    = lmdb::dbi::open(txn, "rooms", MDB_CREATE);
-        invitesDb_ = lmdb::dbi::open(txn, "invites", MDB_CREATE);
-        imagesDb_  = lmdb::dbi::open(txn, "images", MDB_CREATE);
+        auto txn        = lmdb::txn::begin(env_);
+        stateDb_        = lmdb::dbi::open(txn, "state", MDB_CREATE);
+        roomDb_         = lmdb::dbi::open(txn, "rooms", MDB_CREATE);
+        invitesDb_      = lmdb::dbi::open(txn, "invites", MDB_CREATE);
+        imagesDb_       = lmdb::dbi::open(txn, "images", MDB_CREATE);
+        readReceiptsDb_ = lmdb::dbi::open(txn, "read_receipts", MDB_CREATE);
 
         txn.commit();
 
@@ -449,3 +451,94 @@ Cache::setInvites(const std::map<std::string, mtx::responses::InvitedRoom> &invi
                 unmount();
         }
 }
+
+std::multimap<uint64_t, std::string>
+Cache::readReceipts(const QString &event_id, const QString &room_id)
+{
+        std::multimap<uint64_t, std::string> receipts;
+
+        ReadReceiptKey receipt_key{event_id.toStdString(), room_id.toStdString()};
+        nlohmann::json json_key = receipt_key;
+
+        try {
+                auto txn = lmdb::txn::begin(env_, nullptr, MDB_RDONLY);
+                auto key = json_key.dump();
+
+                lmdb::val value;
+
+                bool res =
+                  lmdb::dbi_get(txn, readReceiptsDb_, lmdb::val(key.data(), key.size()), value);
+
+                txn.commit();
+
+                if (res) {
+                        auto json_response = json::parse(std::string(value.data(), value.size()));
+                        auto values        = json_response.get<std::vector<ReadReceiptValue>>();
+
+                        for (auto v : values)
+                                receipts.emplace(v.ts, v.user_id);
+                }
+
+        } catch (const lmdb::error &e) {
+                qCritical() << "readReceipts:" << e.what();
+        }
+
+        return receipts;
+}
+
+using Receipts = std::map<std::string, std::map<std::string, uint64_t>>;
+void
+Cache::updateReadReceipt(const std::string &room_id, const Receipts &receipts)
+{
+        for (auto receipt : receipts) {
+                const auto event_id = receipt.first;
+                auto event_receipts = receipt.second;
+
+                ReadReceiptKey receipt_key{event_id, room_id};
+                nlohmann::json json_key = receipt_key;
+
+                try {
+                        auto read_txn  = lmdb::txn::begin(env_, nullptr, MDB_RDONLY);
+                        const auto key = json_key.dump();
+
+                        lmdb::val prev_value;
+
+                        bool exists = lmdb::dbi_get(
+                          read_txn, readReceiptsDb_, lmdb::val(key.data(), key.size()), prev_value);
+
+                        read_txn.commit();
+
+                        std::vector<ReadReceiptValue> saved_receipts;
+
+                        // If an entry for the event id already exists, we would
+                        // merge the existing receipts with the new ones.
+                        if (exists) {
+                                auto json_value =
+                                  json::parse(std::string(prev_value.data(), prev_value.size()));
+
+                                // Retrieve the saved receipts.
+                                saved_receipts = json_value.get<std::vector<ReadReceiptValue>>();
+                        }
+
+                        // Append the new ones.
+                        for (auto event_receipt : event_receipts)
+                                saved_receipts.push_back(
+                                  ReadReceiptValue{event_receipt.first, event_receipt.second});
+
+                        // Save back the merged (or only the new) receipts.
+                        nlohmann::json json_updated_value = saved_receipts;
+                        std::string merged_receipts       = json_updated_value.dump();
+
+                        auto txn = lmdb::txn::begin(env_);
+
+                        lmdb::dbi_put(txn,
+                                      readReceiptsDb_,
+                                      lmdb::val(key.data(), key.size()),
+                                      lmdb::val(merged_receipts.data(), merged_receipts.size()));
+
+                        txn.commit();
+                } catch (const lmdb::error &e) {
+                        qCritical() << "updateReadReceipts:" << e.what();
+                }
+        }
+}
diff --git a/src/ChatPage.cc b/src/ChatPage.cc
index 071fef71..3958e2c2 100644
--- a/src/ChatPage.cc
+++ b/src/ChatPage.cc
@@ -39,11 +39,14 @@
 #include "UserInfoWidget.h"
 #include "UserSettingsPage.h"
 
+#include "dialogs/ReadReceipts.h"
 #include "timeline/TimelineViewManager.h"
 
 constexpr int MAX_INITIAL_SYNC_FAILURES = 5;
 constexpr int SYNC_RETRY_TIMEOUT        = 10000;
 
+ChatPage *ChatPage::instance_ = nullptr;
+
 ChatPage::ChatPage(QSharedPointer<MatrixClient> client,
                    QSharedPointer<UserSettings> userSettings,
                    QWidget *parent)
@@ -302,6 +305,8 @@ ChatPage::ChatPage(QSharedPointer<MatrixClient> client,
         });
 
         AvatarProvider::init(client);
+
+        instance_ = this;
 }
 
 void
@@ -734,6 +739,12 @@ ChatPage::updateJoinedRooms(const std::map<std::string, mtx::responses::JoinedRo
 
                 updateTypingUsers(roomid, it->second.ephemeral.typing);
 
+                if (it->second.ephemeral.receipts.size() > 0)
+                        QtConcurrent::run(cache_.data(),
+                                          &Cache::updateReadReceipt,
+                                          it->first,
+                                          it->second.ephemeral.receipts);
+
                 const auto newStateEvents    = it->second.state;
                 const auto newTimelineEvents = it->second.timeline;
 
@@ -809,4 +820,25 @@ ChatPage::generateMembershipDifference(
         return stateDiff;
 }
 
+void
+ChatPage::showReadReceipts(const QString &event_id)
+{
+        if (receiptsDialog_.isNull()) {
+                receiptsDialog_ = QSharedPointer<dialogs::ReadReceipts>(
+                  new dialogs::ReadReceipts(this),
+                  [=](dialogs::ReadReceipts *dialog) { dialog->deleteLater(); });
+        }
+
+        if (receiptsModal_.isNull()) {
+                receiptsModal_ = QSharedPointer<OverlayModal>(
+                  new OverlayModal(MainWindow::instance(), receiptsDialog_.data()),
+                  [=](OverlayModal *modal) { modal->deleteLater(); });
+                receiptsModal_->setDuration(0);
+                receiptsModal_->setColor(QColor(30, 30, 30, 170));
+        }
+
+        receiptsDialog_->addUsers(cache_->readReceipts(event_id, current_room_));
+        receiptsModal_->fadeIn();
+}
+
 ChatPage::~ChatPage() {}
diff --git a/src/MainWindow.cc b/src/MainWindow.cc
index 39f58dac..f2b7005b 100644
--- a/src/MainWindow.cc
+++ b/src/MainWindow.cc
@@ -281,10 +281,4 @@ MainWindow::hasActiveUser()
                settings.contains("auth/user_id");
 }
 
-MainWindow *
-MainWindow::instance()
-{
-        return instance_;
-}
-
 MainWindow::~MainWindow() {}
diff --git a/src/dialogs/ReadReceipts.cc b/src/dialogs/ReadReceipts.cc
new file mode 100644
index 00000000..ae28969f
--- /dev/null
+++ b/src/dialogs/ReadReceipts.cc
@@ -0,0 +1,124 @@
+#include <QDebug>
+#include <QIcon>
+#include <QListWidgetItem>
+#include <QPainter>
+#include <QStyleOption>
+#include <QTimer>
+#include <QVBoxLayout>
+
+#include "Config.h"
+
+#include "Avatar.h"
+#include "AvatarProvider.h"
+#include "dialogs/ReadReceipts.h"
+#include "timeline/TimelineViewManager.h"
+
+using namespace dialogs;
+
+ReceiptItem::ReceiptItem(QWidget *parent, const QString &user_id, uint64_t timestamp)
+  : QWidget(parent)
+{
+        topLayout_ = new QHBoxLayout(this);
+        topLayout_->setMargin(0);
+
+        textLayout_ = new QVBoxLayout;
+        textLayout_->setMargin(0);
+        textLayout_->setSpacing(5);
+
+        QFont font;
+        font.setPixelSize(conf::receipts::font);
+
+        auto displayName = TimelineViewManager::displayName(user_id);
+
+        avatar_ = new Avatar(this);
+        avatar_->setSize(40);
+        avatar_->setLetter(QChar(displayName[0]));
+
+        // If it's a matrix id we use the second letter.
+        if (displayName.size() > 1 && displayName.at(0) == '@')
+                avatar_->setLetter(QChar(displayName.at(1)));
+
+        userName_ = new QLabel(displayName, this);
+        userName_->setFont(font);
+
+        timestamp_ = new QLabel(dateFormat(QDateTime::fromMSecsSinceEpoch(timestamp)), this);
+        timestamp_->setFont(font);
+
+        textLayout_->addWidget(userName_);
+        textLayout_->addWidget(timestamp_);
+
+        topLayout_->addWidget(avatar_);
+        topLayout_->addLayout(textLayout_, 1);
+
+        AvatarProvider::resolve(user_id, [=](const QImage &img) { avatar_->setImage(img); });
+}
+
+QString
+ReceiptItem::dateFormat(const QDateTime &then) const
+{
+        auto now  = QDateTime::currentDateTime();
+        auto days = then.daysTo(now);
+
+        if (days == 0)
+                return QString("Today %1").arg(then.toString("HH:mm"));
+        else if (days < 2)
+                return QString("Yesterday %1").arg(then.toString("HH::mm"));
+        else if (days < 365)
+                return then.toString("dd/MM HH:mm");
+
+        return then.toString("dd/MM/yy");
+}
+
+ReadReceipts::ReadReceipts(QWidget *parent)
+  : QFrame(parent)
+{
+        setMaximumSize(400, 350);
+
+        auto layout = new QVBoxLayout(this);
+        layout->setSpacing(30);
+        layout->setMargin(20);
+
+        userList_ = new QListWidget;
+        userList_->setFrameStyle(QFrame::NoFrame);
+        userList_->setSelectionMode(QAbstractItemView::NoSelection);
+        userList_->setAttribute(Qt::WA_MacShowFocusRect, 0);
+        userList_->setSpacing(5);
+
+        QFont font;
+        font.setPixelSize(conf::headerFontSize);
+
+        topLabel_ = new QLabel(tr("Read receipts"), this);
+        topLabel_->setAlignment(Qt::AlignCenter);
+        topLabel_->setFont(font);
+
+        layout->addWidget(topLabel_);
+        layout->addWidget(userList_);
+}
+
+void
+ReadReceipts::addUsers(const std::multimap<uint64_t, std::string> &receipts)
+{
+        // We want to remove any previous items that have been set.
+        userList_->clear();
+
+        for (auto receipt : receipts) {
+                auto user =
+                  new ReceiptItem(this, QString::fromStdString(receipt.second), receipt.first);
+                auto item = new QListWidgetItem(userList_);
+
+                item->setSizeHint(user->minimumSizeHint());
+                item->setFlags(Qt::NoItemFlags);
+                item->setTextAlignment(Qt::AlignCenter);
+
+                userList_->setItemWidget(item, user);
+        }
+}
+
+void
+ReadReceipts::paintEvent(QPaintEvent *)
+{
+        QStyleOption opt;
+        opt.init(this);
+        QPainter p(this);
+        style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this);
+}
diff --git a/src/timeline/TimelineItem.cc b/src/timeline/TimelineItem.cc
index a42edbb7..202b331d 100644
--- a/src/timeline/TimelineItem.cc
+++ b/src/timeline/TimelineItem.cc
@@ -15,10 +15,13 @@
  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
  */
 
+#include <QContextMenuEvent>
 #include <QFontDatabase>
+#include <QMenu>
 #include <QTextEdit>
 
 #include "Avatar.h"
+#include "ChatPage.h"
 #include "Config.h"
 
 #include "timeline/TimelineItem.h"
@@ -39,6 +42,14 @@ TimelineItem::init()
 
         QFontMetrics fm(font_);
 
+        receiptsMenu_     = new QMenu(this);
+        showReadReceipts_ = new QAction("Read receipts", this);
+        receiptsMenu_->addAction(showReadReceipts_);
+        connect(showReadReceipts_, &QAction::triggered, this, [=]() {
+                if (!event_id_.isEmpty())
+                        ChatPage::instance()->showReadReceipts(event_id_);
+        });
+
         topLayout_  = new QHBoxLayout(this);
         sideLayout_ = new QVBoxLayout;
         mainLayout_ = new QVBoxLayout;
@@ -88,7 +99,7 @@ TimelineItem::TimelineItem(mtx::events::MessageType ty,
                 setupAvatarLayout(displayName);
                 mainLayout_->addLayout(headerLayout_);
 
-                AvatarProvider::resolve(userid, this);
+                AvatarProvider::resolve(userid, [=](const QImage &img) { setUserAvatar(img); });
         } else {
                 generateBody(body);
                 setupSimpleLayout();
@@ -213,7 +224,7 @@ TimelineItem::TimelineItem(const mtx::events::RoomEvent<mtx::events::msg::Notice
 
                 mainLayout_->addLayout(headerLayout_);
 
-                AvatarProvider::resolve(sender, this);
+                AvatarProvider::resolve(sender, [=](const QImage &img) { setUserAvatar(img); });
         } else {
                 generateBody(body);
                 setupSimpleLayout();
@@ -252,7 +263,7 @@ TimelineItem::TimelineItem(const mtx::events::RoomEvent<mtx::events::msg::Emote>
                 setupAvatarLayout(displayName);
                 mainLayout_->addLayout(headerLayout_);
 
-                AvatarProvider::resolve(sender, this);
+                AvatarProvider::resolve(sender, [=](const QImage &img) { setUserAvatar(img); });
         } else {
                 generateBody(emoteMsg);
                 setupSimpleLayout();
@@ -297,7 +308,7 @@ TimelineItem::TimelineItem(const mtx::events::RoomEvent<mtx::events::msg::Text>
 
                 mainLayout_->addLayout(headerLayout_);
 
-                AvatarProvider::resolve(sender, this);
+                AvatarProvider::resolve(sender, [=](const QImage &img) { setUserAvatar(img); });
         } else {
                 generateBody(body);
                 setupSimpleLayout();
@@ -472,6 +483,13 @@ TimelineItem::descriptiveTime(const QDateTime &then)
 TimelineItem::~TimelineItem() {}
 
 void
+TimelineItem::contextMenuEvent(QContextMenuEvent *event)
+{
+        if (receiptsMenu_)
+                receiptsMenu_->exec(event->globalPos());
+}
+
+void
 TimelineItem::paintEvent(QPaintEvent *)
 {
         QStyleOption opt;
diff --git a/src/ui/OverlayModal.cc b/src/ui/OverlayModal.cc
index 4fb57175..290d28e5 100644
--- a/src/ui/OverlayModal.cc
+++ b/src/ui/OverlayModal.cc
@@ -47,6 +47,8 @@ OverlayModal::OverlayModal(QWidget *parent, QWidget *content)
                 if (animation_->direction() == QAbstractAnimation::Forward)
                         this->close();
         });
+
+        content->setFocus();
 }
 
 void
@@ -72,3 +74,12 @@ OverlayModal::fadeOut()
         animation_->setDirection(QAbstractAnimation::Forward);
         animation_->start();
 }
+
+void
+OverlayModal::keyPressEvent(QKeyEvent *event)
+{
+        if (event->key() == Qt::Key_Escape) {
+                event->accept();
+                fadeOut();
+        }
+}