diff --git a/src/Cache.cpp b/src/Cache.cpp
index 6f71b746..452567c3 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)
{
@@ -734,6 +822,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)
{
auto txn = lmdb::txn::begin(env_);
@@ -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 fa8355a5..d5d1729e 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 cc7a5741..6f5e31e5 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 88ab1963..696db8de 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);
@@ -496,6 +517,21 @@ TimelineItem::markSent()
}
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)
{
isReceived_ = true;
diff --git a/src/timeline/TimelineItem.h b/src/timeline/TimelineItem.h
index d3cab0a0..874c00df 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 a8c04807..074ba498 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"
@@ -353,6 +354,27 @@ TimelineView::parseEncryptedEvent(const mtx::events::EncryptedEvent<mtx::events:
}
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)
{
int counter = 0;
@@ -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 7b269063..5c42415a 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 1decab35..1bbb4def 100644
--- a/src/timeline/TimelineViewManager.cpp
+++ b/src/timeline/TimelineViewManager.cpp
@@ -37,6 +37,17 @@ TimelineViewManager::TimelineViewManager(QWidget *parent)
}
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)
{
auto view = views_[room_id];
diff --git a/src/timeline/TimelineViewManager.h b/src/timeline/TimelineViewManager.h
index f3c099c1..d23345d3 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);
|