diff --git a/resources/icons/ui/double-tick-indicator.png b/resources/icons/ui/double-tick-indicator.png new file mode 100644 index 00000000..23f45617 Binary files /dev/null and b/resources/icons/ui/double-tick-indicator.png differ 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 00000000..3e99867a Binary files /dev/null and b/resources/icons/ui/double-tick-indicator@2x.png differ diff --git a/resources/res.qrc b/resources/res.qrc index fe3e3104..71463e65 100644 --- a/resources/res.qrc +++ b/resources/res.qrc @@ -1,5 +1,7 @@ + icons/ui/double-tick-indicator.png + icons/ui/double-tick-indicator@2x.png icons/ui/lock.png icons/ui/lock@2x.png icons/ui/clock.png 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 +Cache::pendingReceiptsEvents(lmdb::txn &txn, const std::string &room_id) +{ + auto db = getPendingReceiptsDb(txn); + + std::string key, unused; + std::vector 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 +Cache::filterReadEvents(const QString &room_id, + const std::vector &event_ids, + const std::string &excluded_user) +{ + std::vector 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 fa8355a5..d5d1729e 100644 --- a/src/Cache.h +++ b/src/Cache.h @@ -347,6 +347,18 @@ public: using UserReceipts = std::multimap>; UserReceipts readReceipts(const QString &event_id, const QString &room_id); + //! Filter the events that have at least one read receipt. + std::vector filterReadEvents(const QString &room_id, + const std::vector &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 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 &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, ImageItem>( image, event, with_sender); + markOwnMessagesAsReceived(event.sender); + addSaveImageAction(image); } @@ -315,6 +322,8 @@ TimelineItem::TimelineItem(StickerItem *image, { setupWidgetLayout(image, event, with_sender); + markOwnMessagesAsReceived(event.sender); + addSaveImageAction(image); } @@ -328,6 +337,8 @@ TimelineItem::TimelineItem(FileItem *file, { setupWidgetLayout, FileItem>( file, event, with_sender); + + markOwnMessagesAsReceived(event.sender); } TimelineItem::TimelineItem(AudioItem *audio, @@ -340,6 +351,8 @@ TimelineItem::TimelineItem(AudioItem *audio, { setupWidgetLayout, AudioItem>( audio, event, with_sender); + + markOwnMessagesAsReceived(event.sender); } TimelineItem::TimelineItem(VideoItem *video, @@ -352,6 +365,8 @@ TimelineItem::TimelineItem(VideoItem *video, { setupWidgetLayout, VideoItem>( video, event, with_sender); + + markOwnMessagesAsReceived(event.sender); } /* @@ -367,6 +382,8 @@ TimelineItem::TimelineItem(const mtx::events::RoomEvent 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 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 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 #include #include +#include #include "Cache.h" #include "ChatPage.h" @@ -352,6 +353,27 @@ TimelineView::parseEncryptedEvent(const mtx::events::EncryptedEvent events) +{ + QtConcurrent::run( + [events = std::move(events), room_id = room_id_, local_user = local_user_, this]() { + std::vector 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 &events) { @@ -373,6 +395,8 @@ TimelineView::renderBottomEvents(const std::vector &events) lastMessageDirection_ = TimelineDirection::Bottom; + displayReadReceipts(events); + QApplication::processEvents(); } @@ -407,6 +431,8 @@ TimelineView::renderTopEvents(const std::vector &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 &event_ids) { + for (const auto &event : event_ids) { + if (eventIds_.contains(event)) { + auto widget = eventIds_[event]; + if (!widget) + return; + + auto item = qobject_cast(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 &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 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 @@ -36,6 +36,17 @@ TimelineViewManager::TimelineViewManager(QWidget *parent) setStyleSheet("border: none;"); } +void +TimelineViewManager::updateReadReceipts(const QString &room_id, + const std::vector &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 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 &event_ids); void removeTimelineEvent(const QString &room_id, const QString &event_id); void initWithMessages(const std::map &msgs);