aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAlexey Rusakov <Kitsune-Ral@users.sf.net>2021-11-17 19:37:20 +0100
committerGitHub <noreply@github.com>2021-11-17 19:37:20 +0100
commitd02a6ffc90d0d2a7984560ec28241be228014fac (patch)
tree4a2a869a2ce437ba17c7269e8e904276a11db62a
parentd5515bb6dd8ebf46aa36a8bc696b90a573a8492c (diff)
parent0052c0b2b4f9860ee9223ef161f40929e7c4c454 (diff)
downloadlibquotient-d02a6ffc90d0d2a7984560ec28241be228014fac.tar.gz
libquotient-d02a6ffc90d0d2a7984560ec28241be228014fac.zip
Merge pull request #487 from quotient-im/kitsune-fix-read-receipts-and-markers
Distinguish between read receipts and the fully read marker
-rw-r--r--lib/converters.h7
-rw-r--r--lib/events/receiptevent.cpp29
-rw-r--r--lib/events/receiptevent.h5
-rw-r--r--lib/room.cpp451
-rw-r--r--lib/room.h149
-rw-r--r--lib/syncdata.cpp7
-rw-r--r--lib/syncdata.h3
7 files changed, 439 insertions, 212 deletions
diff --git a/lib/converters.h b/lib/converters.h
index 2df18b03..d9a68bfb 100644
--- a/lib/converters.h
+++ b/lib/converters.h
@@ -134,7 +134,10 @@ struct JsonConverter<QString> : public TrivialJsonDumper<QString> {
template <>
struct JsonConverter<QDateTime> {
- static auto dump(const QDateTime& val) = delete; // not provided yet
+ static auto dump(const QDateTime& val)
+ {
+ return val.isValid() ? val.toMSecsSinceEpoch() : QJsonValue();
+ }
static auto load(const QJsonValue& jv)
{
return QDateTime::fromMSecsSinceEpoch(fromJson<qint64>(jv), Qt::UTC);
@@ -143,7 +146,7 @@ struct JsonConverter<QDateTime> {
template <>
struct JsonConverter<QDate> {
- static auto dump(const QDate& val) = delete; // not provided yet
+ static auto dump(const QDate& val) { return toJson(QDateTime(val)); }
static auto load(const QJsonValue& jv)
{
return fromJson<QDateTime>(jv).date();
diff --git a/lib/events/receiptevent.cpp b/lib/events/receiptevent.cpp
index 4185d92d..72dbf2e3 100644
--- a/lib/events/receiptevent.cpp
+++ b/lib/events/receiptevent.cpp
@@ -25,6 +25,27 @@ Example of a Receipt Event:
using namespace Quotient;
+// The library loads the event-ids-to-receipts JSON map into a vector because
+// map lookups are not used and vectors are massively faster. Same goes for
+// de-/serialization of ReceiptsForEvent::receipts.
+// (XXX: would this be generally preferred across CS API JSON maps?..)
+QJsonObject toJson(const EventsWithReceipts& ewrs)
+{
+ QJsonObject json;
+ for (const auto& e : ewrs) {
+ QJsonObject receiptsJson;
+ for (const auto& r : e.receipts)
+ receiptsJson.insert(r.userId,
+ QJsonObject { { "ts"_ls, toJson(r.timestamp) } });
+ json.insert(e.evtId, QJsonObject { { "m.read"_ls, receiptsJson } });
+ }
+ return json;
+}
+
+ReceiptEvent::ReceiptEvent(const EventsWithReceipts &ewrs)
+ : Event(typeId(), matrixTypeId(), toJson(ewrs))
+{}
+
EventsWithReceipts ReceiptEvent::eventsWithReceipts() const
{
EventsWithReceipts result;
@@ -39,14 +60,14 @@ EventsWithReceipts ReceiptEvent::eventsWithReceipts() const
}
const auto reads =
eventIt.value().toObject().value("m.read"_ls).toObject();
- QVector<Receipt> receipts;
- receipts.reserve(reads.size());
+ QVector<UserTimestamp> usersAtEvent;
+ usersAtEvent.reserve(reads.size());
for (auto userIt = reads.begin(); userIt != reads.end(); ++userIt) {
const auto user = userIt.value().toObject();
- receipts.push_back(
+ usersAtEvent.push_back(
{ userIt.key(), fromJson<QDateTime>(user["ts"_ls]) });
}
- result.push_back({ eventIt.key(), std::move(receipts) });
+ result.push_back({ eventIt.key(), std::move(usersAtEvent) });
}
return result;
}
diff --git a/lib/events/receiptevent.h b/lib/events/receiptevent.h
index 4feec9ea..9683deef 100644
--- a/lib/events/receiptevent.h
+++ b/lib/events/receiptevent.h
@@ -9,19 +9,20 @@
#include <QtCore/QVector>
namespace Quotient {
-struct Receipt {
+struct UserTimestamp {
QString userId;
QDateTime timestamp;
};
struct ReceiptsForEvent {
QString evtId;
- QVector<Receipt> receipts;
+ QVector<UserTimestamp> receipts;
};
using EventsWithReceipts = QVector<ReceiptsForEvent>;
class ReceiptEvent : public Event {
public:
DEFINE_EVENT_TYPEID("m.receipt", ReceiptEvent)
+ explicit ReceiptEvent(const EventsWithReceipts& ewrs);
explicit ReceiptEvent(const QJsonObject& obj) : Event(typeId(), obj) {}
EventsWithReceipts eventsWithReceipts() const;
diff --git a/lib/room.cpp b/lib/room.cpp
index ecc9859d..a06a8d2a 100644
--- a/lib/room.cpp
+++ b/lib/room.cpp
@@ -120,15 +120,15 @@ public:
int notificationCount = 0;
members_map_t membersMap;
QList<User*> usersTyping;
- QMultiHash<QString, User*> eventIdReadUsers;
+ QHash<QString, QSet<QString>> eventIdReadUsers;
QList<User*> usersInvited;
QList<User*> membersLeft;
int unreadMessages = 0;
bool displayed = false;
QString firstDisplayedEventId;
QString lastDisplayedEventId;
- QHash<const User*, QString> lastReadEventIds;
- QString serverReadMarker;
+ QHash<QString, ReadReceipt> lastReadReceipts;
+ QString fullyReadUntilEventId;
TagsMap tags;
UnorderedMap<QString, EventPtr> accountData;
QString prevBatch;
@@ -291,11 +291,12 @@ public:
*/
void dropDuplicateEvents(RoomEvents& events) const;
- Changes setLastReadEvent(User* u, QString eventId);
- void updateUnreadCount(const rev_iter_t& from, const rev_iter_t& to);
- Changes promoteReadMarker(User* u, const rev_iter_t& newMarker, bool force = false);
-
- Changes markMessagesAsRead(rev_iter_t upToMarker);
+ bool setLastReadReceipt(const QString& userId, rev_iter_t newMarker,
+ ReadReceipt newReceipt = {});
+ Changes setFullyReadMarker(const QString &eventId);
+ Changes updateUnreadCount(const rev_iter_t& from, const rev_iter_t& to);
+ Changes recalculateUnreadCount(bool force = false);
+ void markMessagesAsRead(const rev_iter_t &upToMarker);
void getAllMembers();
@@ -618,143 +619,197 @@ void Room::setJoinState(JoinState state)
emit joinStateChanged(oldState, state);
}
-Room::Changes Room::Private::setLastReadEvent(User* u, QString eventId)
-{
- auto& storedId = lastReadEventIds[u];
- if (storedId == eventId)
- return Change::NoChange;
- eventIdReadUsers.remove(storedId, u);
- eventIdReadUsers.insert(eventId, u);
- swap(storedId, eventId);
- emit q->lastReadEventChanged(u);
- emit q->readMarkerForUserMoved(u, eventId, storedId);
- if (isLocalUser(u)) {
- if (storedId != serverReadMarker)
- connection->callApi<SetReadMarkerJob>(BackgroundRequest, id,
- storedId);
- emit q->readMarkerMoved(eventId, storedId);
+bool Room::Private::setLastReadReceipt(const QString& userId,
+ rev_iter_t newMarker,
+ ReadReceipt newReceipt)
+{
+ if (newMarker == historyEdge() && !newReceipt.eventId.isEmpty())
+ newMarker = q->findInTimeline(newReceipt.eventId);
+ if (newMarker != historyEdge()) {
+ // Try to auto-promote the read marker over the user's own messages
+ // (switch to direct iterators for that).
+ const auto eagerMarker = find_if(newMarker.base(), syncEdge(),
+ [=](const TimelineItem& ti) {
+ return ti->senderId() != userId;
+ });
+ // eagerMarker is now just after the desired event for newMarker
+ if (eagerMarker != newMarker.base()) {
+ qCDebug(EPHEMERAL) << "Auto-promoted read receipt for" << userId
+ << "to" << newReceipt.eventId;
+ newMarker = rev_iter_t(eagerMarker);
+ }
+ // Fill newReceipt with the event (and, if needed, timestamp) from
+ // eagerMarker
+ newReceipt.eventId = (eagerMarker - 1)->event()->id();
+ if (newReceipt.timestamp.isNull())
+ newReceipt.timestamp = QDateTime::currentDateTime();
+ }
+ auto& storedReceipt =
+ lastReadReceipts[userId]; // clazy:exclude=detaching-member
+ const auto prevEventId = storedReceipt.eventId;
+ // NB: with reverse iterators, timeline history >= sync edge
+ if (newMarker >= q->findInTimeline(prevEventId))
+ return false;
- // NB: GCC (at least 10) only accepts QT_IGNORE_DEPRECATIONS around
- // a statement, not within a statement
- QT_IGNORE_DEPRECATIONS( // TODO: Drop ReadMarkerChange in 0.8
- return Change::ReadMarkerChange | Change::OtherChange;
- )
- }
- return Change::NoChange;
+ // Finally make the change
+
+ auto oldEventReadUsersIt =
+ eventIdReadUsers.find(prevEventId); // clazy:exclude=detaching-member
+ if (oldEventReadUsersIt != eventIdReadUsers.end()) {
+ oldEventReadUsersIt->remove(userId);
+ if (oldEventReadUsersIt->isEmpty())
+ eventIdReadUsers.erase(oldEventReadUsersIt);
+ }
+ eventIdReadUsers[newReceipt.eventId].insert(userId);
+ storedReceipt = move(newReceipt);
+ qCDebug(EPHEMERAL) << "The new read receipt for" << userId << "is now at"
+ << storedReceipt.eventId;
+
+ // TODO: use Room::member() when it becomes a thing and only emit signals
+ // for actual members, not just any user
+ const auto member = q->user(userId);
+ Q_ASSERT(member != nullptr);
+ emit q->lastReadEventChanged(member);
+ // TODO: remove in 0.8
+ if (!isLocalUser(member))
+ emit q->readMarkerForUserMoved(member, prevEventId,
+ storedReceipt.eventId);
+ return true;
}
-void Room::Private::updateUnreadCount(const rev_iter_t& from,
- const rev_iter_t& to)
+Room::Changes Room::Private::updateUnreadCount(const rev_iter_t& from,
+ const rev_iter_t& to)
{
Q_ASSERT(from >= timeline.crbegin() && from <= timeline.crend());
Q_ASSERT(to >= from && to <= timeline.crend());
- // Catch a special case when the last read event id refers to an event
- // that has just arrived. In this case we should recalculate
- // unreadMessages and might need to promote the read marker further
- // over local-origin messages.
- auto readMarker = q->readMarker();
- if (readMarker == historyEdge() && q->allHistoryLoaded())
- --readMarker; // Read marker not found in the timeline, initialise it
- if (readMarker >= from && readMarker < to) {
- promoteReadMarker(q->localUser(), readMarker, true);
- return;
- }
-
- Q_ASSERT(to <= readMarker);
+ auto fullyReadMarker = q->fullyReadMarker();
+ if (fullyReadMarker < from)
+ return NoChange; // What's arrived is already fully read
+
+ // If there's no read marker in the whole room, initialise it
+ if (fullyReadMarker == historyEdge() && q->allHistoryLoaded())
+ return setFullyReadMarker(timeline.front()->id());
+
+ // Catch a special case when the last fully read event id refers to an
+ // event that has just arrived. In this case we should recalculate
+ // unreadMessages to get an exact number instead of an estimation
+ // (see https://github.com/quotient-im/libQuotient/wiki/unread_count).
+ // For the same reason (switching from the estimation to the exact
+ // number) this branch always emits unreadMessagesChanged() and returns
+ // UnreadNotifsChange, even if the estimation luckily matched the exact
+ // result.
+ if (fullyReadMarker < to)
+ return recalculateUnreadCount(true);
+
+ // At this point the fully read marker is somewhere beyond the "oldest"
+ // message from the arrived batch - add up newly arrived messages to
+ // the current counter, instead of a complete recalculation.
+ Q_ASSERT(to <= fullyReadMarker);
QElapsedTimer et;
et.start();
const auto newUnreadMessages =
- count_if(from, to, std::bind(&Room::Private::isEventNotable, this, _1));
+ count_if(from, to,
+ std::bind(&Room::Private::isEventNotable, this, _1));
if (et.nsecsElapsed() > profilerMinNsecs() / 10)
qCDebug(PROFILER) << "Counting gained unread messages took" << et;
- if (newUnreadMessages > 0) {
- // See https://github.com/quotient-im/libQuotient/wiki/unread_count
- if (unreadMessages < 0)
- unreadMessages = 0;
-
- unreadMessages += newUnreadMessages;
- qCDebug(MESSAGES) << "Room" << q->objectName() << "has gained"
- << newUnreadMessages << "unread message(s),"
- << (q->readMarker() == timeline.crend()
- ? "in total at least"
- : "in total")
- << unreadMessages << "unread message(s)";
- emit q->unreadMessagesChanged(q);
- }
-}
+ if (newUnreadMessages == 0)
+ return NoChange;
-Room::Changes Room::Private::promoteReadMarker(User* u,
- const rev_iter_t& newMarker,
- bool force)
-{
- Q_ASSERT_X(u, __FUNCTION__, "User* should not be nullptr");
- Q_ASSERT(newMarker >= timeline.crbegin() && newMarker <= timeline.crend());
+ // See https://github.com/quotient-im/libQuotient/wiki/unread_count
+ if (unreadMessages < 0)
+ unreadMessages = 0;
+
+ unreadMessages += newUnreadMessages;
+ qCDebug(MESSAGES) << "Room" << q->objectName() << "has gained"
+ << newUnreadMessages << "unread message(s),"
+ << (q->fullyReadMarker() == timeline.crend()
+ ? "in total at least"
+ : "in total")
+ << unreadMessages << "unread message(s)";
+ emit q->unreadMessagesChanged(q);
+ return UnreadNotifsChange;
+}
+
+Room::Changes Room::Private::recalculateUnreadCount(bool force)
+{
+ // The recalculation logic assumes that the fully read marker points at
+ // a specific position in the timeline
+ Q_ASSERT(q->fullyReadMarker() != timeline.crend());
+ const auto oldUnreadCount = unreadMessages;
+ QElapsedTimer et;
+ et.start();
+ unreadMessages =
+ int(count_if(timeline.crbegin(), q->fullyReadMarker(),
+ [this](const auto& ti) { return isEventNotable(ti); }));
+ if (et.nsecsElapsed() > profilerMinNsecs() / 10)
+ qCDebug(PROFILER) << "Recounting unread messages took" << et;
- const auto prevMarker = q->readMarker(u);
- if (!force && prevMarker <= newMarker) // Remember, we deal with reverse
- // iterators
- return Change::NoChange;
+ // See https://github.com/quotient-im/libQuotient/wiki/unread_count
+ if (unreadMessages == 0)
+ unreadMessages = -1;
- Q_ASSERT(newMarker < historyEdge());
+ if (!force && unreadMessages == oldUnreadCount)
+ return NoChange;
- // Try to auto-promote the read marker over the user's own messages
- // (switch to direct iterators for that).
- auto eagerMarker =
- find_if(newMarker.base(), timeline.cend(), [=](const TimelineItem& ti) {
- return ti->senderId() != u->id();
- });
+ if (unreadMessages == -1)
+ qCDebug(MESSAGES)
+ << "Room" << displayname << "has no more unread messages";
+ else
+ qCDebug(MESSAGES) << "Room" << displayname << "still has"
+ << unreadMessages << "unread message(s)";
+ emit q->unreadMessagesChanged(q);
+ return UnreadNotifsChange;
+}
- auto changes = setLastReadEvent(u, (*(eagerMarker - 1))->id());
- if (isLocalUser(u)) {
- const auto oldUnreadCount = unreadMessages;
- QElapsedTimer et;
- et.start();
- unreadMessages =
- int(count_if(eagerMarker, syncEdge(),
- [this](const auto& ti) { return isEventNotable(ti); }));
- if (et.nsecsElapsed() > profilerMinNsecs() / 10)
- qCDebug(PROFILER) << "Recounting unread messages took" << et;
+Room::Changes Room::Private::setFullyReadMarker(const QString& eventId)
+{
+ if (fullyReadUntilEventId == eventId)
+ return NoChange;
- // See https://github.com/quotient-im/libQuotient/wiki/unread_count
- if (unreadMessages == 0)
- unreadMessages = -1;
+ const auto prevFullyReadId = std::exchange(fullyReadUntilEventId, eventId);
+ qCDebug(MESSAGES) << "Fully read marker in" << q->objectName() //
+ << "set to" << fullyReadUntilEventId;
+ emit q->fullyReadMarkerMoved(prevFullyReadId, fullyReadUntilEventId);
+ // TODO: Remove in 0.8
+ emit q->readMarkerMoved(prevFullyReadId, fullyReadUntilEventId);
- if (force || unreadMessages != oldUnreadCount) {
- if (unreadMessages == -1) {
- qCDebug(MESSAGES)
- << "Room" << displayname << "has no more unread messages";
- } else
- qCDebug(MESSAGES) << "Room" << displayname << "still has"
- << unreadMessages << "unread message(s)";
- emit q->unreadMessagesChanged(q);
- changes |= Change::UnreadNotifsChange;
- }
+ Changes changes = ReadMarkerChange;
+ if (const auto rm = q->fullyReadMarker(); rm != historyEdge()) {
+ // Pull read receipt if it's behind
+ setLastReadReceipt(connection->userId(), rm);
+ changes |= recalculateUnreadCount(); // TODO: updateUnreadCount()?
}
return changes;
}
-Room::Changes Room::Private::markMessagesAsRead(rev_iter_t upToMarker)
+void Room::setReadReceipt(const QString& atEventId)
{
- const auto prevMarker = q->readMarker();
- auto changes = promoteReadMarker(q->localUser(), upToMarker);
- if (prevMarker != upToMarker)
- qCDebug(MESSAGES) << "Marked messages as read until" << *q->readMarker();
+ if (!d->setLastReadReceipt(localUser()->id(), historyEdge(),
+ { atEventId, QDateTime::currentDateTime() })) {
+ qCDebug(EPHEMERAL) << "The new read receipt for" << localUser()->id()
+ << "in" << objectName()
+ << "is at or behind the old one, skipping";
+ return;
+ }
+ connection()->callApi<PostReceiptJob>(BackgroundRequest, id(),
+ QStringLiteral("m.read"),
+ QUrl::toPercentEncoding(atEventId));
+}
- // We shouldn't send read receipts for the local user's own messages - so
- // search earlier messages for the latest message not from the local user
- // until the previous last-read message, whichever comes first.
- for (; upToMarker < prevMarker; ++upToMarker) {
- if ((*upToMarker)->senderId() != q->localUser()->id()) {
- connection->callApi<PostReceiptJob>(BackgroundRequest,
- id, QStringLiteral("m.read"),
- (*upToMarker)->id());
- break;
- }
+void Room::Private::markMessagesAsRead(const rev_iter_t &upToMarker)
+{
+ if (upToMarker < q->fullyReadMarker()) {
+ setFullyReadMarker((*upToMarker)->id());
+ // Assuming that if a read receipt was sent on a newer event, it will
+ // stay there instead of "un-reading" notifications/mentions from
+ // m.fully_read to m.read
+ connection->callApi<SetReadMarkerJob>(BackgroundRequest, id,
+ fullyReadUntilEventId,
+ fullyReadUntilEventId);
}
- return changes;
}
void Room::markMessagesAsRead(QString uptoEventId)
@@ -885,11 +940,8 @@ void Room::setDisplayed(bool displayed)
d->displayed = displayed;
emit displayedChanged(displayed);
- if (displayed) {
- resetHighlightCount();
- resetNotificationCount();
+ if (displayed)
d->getAllMembers();
- }
}
QString Room::firstDisplayedEventId() const { return d->firstDisplayedEventId; }
@@ -950,19 +1002,48 @@ void Room::setLastDisplayedEvent(TimelineItem::index_t index)
Room::rev_iter_t Room::readMarker(const User* user) const
{
Q_ASSERT(user);
- return findInTimeline(d->lastReadEventIds.value(user));
+ return findInTimeline(lastReadReceipt(user->id()).eventId);
+}
+
+Room::rev_iter_t Room::readMarker() const { return fullyReadMarker(); }
+
+QString Room::readMarkerEventId() const { return lastFullyReadEventId(); }
+
+ReadReceipt Room::lastReadReceipt(const QString& userId) const
+{
+ return d->lastReadReceipts.value(userId);
}
-Room::rev_iter_t Room::readMarker() const { return readMarker(localUser()); }
+ReadReceipt Room::lastLocalReadReceipt() const
+{
+ return d->lastReadReceipts.value(localUser()->id());
+}
-QString Room::readMarkerEventId() const
+Room::rev_iter_t Room::localReadReceiptMarker() const
{
- return d->lastReadEventIds.value(localUser());
+ return findInTimeline(lastLocalReadReceipt().eventId);
}
-QList<User*> Room::usersAtEventId(const QString& eventId)
+QString Room::lastFullyReadEventId() const { return d->fullyReadUntilEventId; }
+
+Room::rev_iter_t Room::fullyReadMarker() const
{
- return d->eventIdReadUsers.values(eventId);
+ return findInTimeline(d->fullyReadUntilEventId);
+}
+
+QSet<QString> Room::userIdsAtEvent(const QString& eventId)
+{
+ return d->eventIdReadUsers.value(eventId);
+}
+
+QSet<User*> Room::usersAtEventId(const QString& eventId)
+{
+ const auto& userIds = d->eventIdReadUsers.value(eventId);
+ QSet<User*> users;
+ users.reserve(userIds.size());
+ for (const auto& uId : userIds)
+ users.insert(user(uId));
+ return users;
}
int Room::notificationCount() const { return d->notificationCount; }
@@ -2444,25 +2525,17 @@ Room::Changes Room::Private::addNewMessageEvents(RoomEvents&& events)
<< totalInserted << "new events; the last event is now"
<< timeline.back();
- // The first event in the just-added batch (referred to by `from`)
- // defines whose read marker can possibly be promoted any further over
- // the same author's events newly arrived. Others will need explicit
- // read receipts from the server (or, for the local user,
- // markMessagesAsRead() invocation) to promote their read markers over
- // the new message events.
- if (const auto senderId = (*from)->senderId(); !senderId.isEmpty()) {
- auto* const firstWriter = q->user(senderId);
- if (q->readMarker(firstWriter) != historyEdge()) {
- roomChanges |=
- promoteReadMarker(firstWriter, rev_iter_t(from) - 1);
- qCDebug(MESSAGES)
- << "Auto-promoted read marker for" << senderId
- << "to" << *q->readMarker(firstWriter);
- }
- }
+ const auto& firstWriterId = (*from)->senderId();
+ setLastReadReceipt(firstWriterId, rev_iter_t(from + 1));
+ // If the local user's message(s) is/are first in the batch
+ // and the fully read marker was right before it, promote
+ // the fully read marker to the same event as the read receipt.
+ if (firstWriterId == connection->userId()
+ && q->fullyReadMarker().base() == from) //
+ roomChanges |=
+ setFullyReadMarker(q->lastReadReceipt(firstWriterId).eventId);
- updateUnreadCount(timeline.crbegin(), rev_iter_t(from));
- roomChanges |= Change::UnreadNotifsChange;
+ roomChanges |= updateUnreadCount(timeline.crbegin(), rev_iter_t(from));
}
Q_ASSERT(timeline.size() == timelineSize + totalInserted);
@@ -2507,8 +2580,11 @@ void Room::Private::addHistoricalMessageEvents(RoomEvents&& events)
emit q->updatedEvent(relation.eventId);
}
}
- if (from <= q->readMarker())
- updateUnreadCount(from, historyEdge());
+ updateUnreadCount(from, historyEdge());
+ // When there are no unread messages and the read marker is within the
+ // known timeline, unreadMessages == -1
+ // (see https://github.com/quotient-im/libQuotient/wiki/unread_count).
+ Q_ASSERT(unreadMessages != 0 || q->fullyReadMarker() == historyEdge());
Q_ASSERT(timeline.size() == timelineSize + insertedSize);
if (insertedSize > 9 || et.nsecsElapsed() >= profilerMinNsecs())
@@ -2731,6 +2807,7 @@ Room::Changes Room::processEphemeralEvent(EventPtr&& event)
et.start();
if (auto* evt = eventCast<TypingEvent>(event)) {
d->usersTyping.clear();
+ d->usersTyping.reserve(evt->users().size()); // Assume all are members
for (const auto& userId : evt->users())
if (isMember(userId))
d->usersTyping.append(user(userId));
@@ -2745,40 +2822,29 @@ Room::Changes Room::processEphemeralEvent(EventPtr&& event)
const auto& eventsWithReceipts = evt->eventsWithReceipts();
for (const auto& p : eventsWithReceipts) {
totalReceipts += p.receipts.size();
- {
- if (p.receipts.size() == 1)
- qCDebug(EPHEMERAL) << "Marking" << p.evtId << "as read for"
- << p.receipts[0].userId;
- else
- qCDebug(EPHEMERAL) << "Marking" << p.evtId << "as read for"
- << p.receipts.size() << "users";
- }
const auto newMarker = findInTimeline(p.evtId);
- if (newMarker != historyEdge()) {
- for (const Receipt& r : p.receipts) {
- if (r.userId == connection()->userId())
- continue; // FIXME, #185
- if (isMember(r.userId))
- changes |=
- d->promoteReadMarker(user(r.userId), newMarker);
- }
- } else {
- qCDebug(EPHEMERAL) << "Event" << p.evtId
- << "not found; saving read receipts anyway";
- // If the event is not found (most likely, because it's too old
- // and hasn't been fetched from the server yet), but there is
- // a previous marker for a user, keep the previous marker.
- // Otherwise, blindly store the event id for this user.
- for (const Receipt& r : p.receipts) {
- if (r.userId == connection()->userId())
- continue; // FIXME, #185
- if (!isMember(r.userId))
- continue;
- auto u = user(r.userId);
- if (readMarker(u) == historyEdge())
- changes |= d->setLastReadEvent(u, p.evtId);
- }
- }
+ if (newMarker == historyEdge())
+ qCDebug(EPHEMERAL) << "Event of the read receipt(s) is not "
+ "found; saving anyway";
+ // If the event is not found (most likely, because it's too old and
+ // hasn't been fetched from the server yet) but there is a previous
+ // marker for a user, keep the previous marker because read receipts
+ // are not supposed to move backwards. Otherwise, blindly store
+ // the event id for this user and update the read marker when/if
+ // the event is fetched later on.
+ const auto updatedCount = std::count_if(
+ p.receipts.cbegin(), p.receipts.cend(),
+ [this, &newMarker, &evtId = p.evtId](const auto& r) -> bool {
+ return d->setLastReadReceipt(r.userId, newMarker,
+ { evtId, r.timestamp });
+ });
+
+ if (p.receipts.size() > 1)
+ qCDebug(EPHEMERAL) << p.evtId << "marked as read for"
+ << updatedCount << "user(s)";
+ if (updatedCount < p.receipts.size())
+ qCDebug(EPHEMERAL) << p.receipts.size() - updatedCount
+ << "receipts were skipped";
}
if (eventsWithReceipts.size() > 3 || totalReceipts > 10
|| et.nsecsElapsed() >= profilerMinNsecs())
@@ -2798,15 +2864,9 @@ Room::Changes Room::processAccountDataEvent(EventPtr&& event)
changes |= Change::TagsChange;
}
- if (auto* evt = eventCast<ReadMarkerEvent>(event)) {
- auto readEventId = evt->event_id();
- qCDebug(STATE) << "Server-side read marker at" << readEventId;
- d->serverReadMarker = readEventId;
- const auto newMarker = findInTimeline(readEventId);
- changes |= newMarker != historyEdge()
- ? d->markMessagesAsRead(newMarker)
- : d->setLastReadEvent(localUser(), readEventId);
- }
+ if (auto* evt = eventCast<const ReadMarkerEvent>(event))
+ changes |= d->setFullyReadMarker(evt->event_id());
+
// For all account data events
auto& currentData = d->accountData[event->matrixType()];
// A polymorphic event-specific comparison might be a bit more
@@ -2990,6 +3050,19 @@ QJsonObject Room::Private::toJson() const
{ QStringLiteral("events"), accountDataEvents } });
}
+ if (const auto& readReceipt = q->lastReadReceipt(connection->userId());
+ !readReceipt.eventId.isEmpty()) //
+ {
+ result.insert(
+ QStringLiteral("ephemeral"),
+ QJsonObject {
+ { QStringLiteral("events"),
+ QJsonArray { ReceiptEvent({ { readReceipt.eventId,
+ { { connection->userId(),
+ readReceipt.timestamp } } } })
+ .fullJson() } } });
+ }
+
QJsonObject unreadNotifObj { { SyncRoomData::UnreadCountKey,
unreadMessages } };
diff --git a/lib/room.h b/lib/room.h
index 873c47d3..d94de51c 100644
--- a/lib/room.h
+++ b/lib/room.h
@@ -71,6 +71,31 @@ public:
bool failed() const { return status == Failed; }
};
+//! \brief Data structure for a room member's read receipt
+//! \sa Room::lastReadReceipt
+class ReadReceipt {
+ Q_GADGET
+ Q_PROPERTY(QString eventId MEMBER eventId CONSTANT)
+ Q_PROPERTY(QDateTime timestamp MEMBER timestamp CONSTANT)
+public:
+ QString eventId;
+ QDateTime timestamp;
+
+ bool operator==(const ReadReceipt& other) const
+ {
+ return eventId == other.eventId && timestamp == other.timestamp;
+ }
+ bool operator!=(const ReadReceipt& other) const
+ {
+ return !operator==(other);
+ }
+};
+inline void swap(ReadReceipt& lhs, ReadReceipt& rhs)
+{
+ swap(lhs.eventId, rhs.eventId);
+ swap(lhs.timestamp, rhs.timestamp);
+}
+
class Room : public QObject {
Q_OBJECT
Q_PROPERTY(Connection* connection READ connection CONSTANT)
@@ -104,9 +129,11 @@ class Room : public QObject {
setFirstDisplayedEventId NOTIFY firstDisplayedEventChanged)
Q_PROPERTY(QString lastDisplayedEventId READ lastDisplayedEventId WRITE
setLastDisplayedEventId NOTIFY lastDisplayedEventChanged)
-
+ //! \deprecated since 0.7
Q_PROPERTY(QString readMarkerEventId READ readMarkerEventId WRITE
markMessagesAsRead NOTIFY readMarkerMoved)
+ Q_PROPERTY(QString lastFullyReadEventId READ lastFullyReadEventId WRITE
+ markMessagesAsRead NOTIFY fullyReadMarkerMoved)
Q_PROPERTY(bool hasUnreadMessages READ hasUnreadMessages NOTIFY
unreadMessagesChanged STORED false)
Q_PROPERTY(int unreadCount READ unreadCount NOTIFY unreadMessagesChanged)
@@ -353,21 +380,108 @@ public:
void setLastDisplayedEventId(const QString& eventId);
void setLastDisplayedEvent(TimelineItem::index_t index);
+ //! \brief Obtain a read receipt of any user
+ //! \deprecated Use lastReadReceipt or fullyReadMarker instead.
+ //!
+ //! Historically, readMarker was returning a "converged" read marker
+ //! representing both the read receipt and the fully read marker, as
+ //! Quotient managed them together. Since 0.6.8, a single-argument call of
+ //! readMarker returns the last read receipt position (for any room member)
+ //! and a call without arguments returns the last _fully read_ position,
+ //! to provide access to both positions separately while maintaining API
+ //! stability guarantees. 0.7 has separate methods to return read receipts
+ //! and the fully read marker - use them instead.
+ //! \sa lastReadReceipt
+ [[deprecated("Use lastReadReceipt() to get m.read receipt or"
+ " fullyReadMarker() to get m.fully_read marker")]] //
rev_iter_t readMarker(const User* user) const;
+ //! \brief Obtain the local user's fully-read marker
+ //! \deprecated Use fullyReadMarker instead
+ //!
+ //! See the documentation for the single-argument overload.
+ //! \sa fullyReadMarker
+ [[deprecated("Use localReadReceiptMarker() or fullyReadMarker()")]] //
rev_iter_t readMarker() const;
+ //! \brief Get the event id for the local user's fully-read marker
+ //! \deprecated Use lastFullyReadEventId instead
+ //!
+ //! See the readMarker documentation
+ [[deprecated("Use lastReadReceipt() to get m.read receipt or"
+ " lastFullyReadEventId() to get an event id that"
+ " m.fully_read marker points to")]] //
QString readMarkerEventId() const;
- QList<User*> usersAtEventId(const QString& eventId);
- /**
- * \brief Mark the event with uptoEventId as read
- *
- * Finds in the timeline and marks as read the event with
- * the specified id; also posts a read receipt to the server either
- * for this message or, if it's from the local user, for
- * the nearest non-local message before. uptoEventId must be non-empty.
- */
- void markMessagesAsRead(QString uptoEventId);
- /// Check whether there are unread messages in the room
+ //! \brief Get the latest read receipt from a user
+ //!
+ //! The user id must be valid. A read receipt with an empty event id
+ //! is returned if the user id is valid but there was no read receipt
+ //! from them.
+ //! \sa usersAtEventId
+ ReadReceipt lastReadReceipt(const QString& userId) const;
+
+ //! \brief Get the latest read receipt from the local user
+ //!
+ //! This is a shortcut for <tt>lastReadReceipt(localUserId)</tt>.
+ //! \sa lastReadReceipt
+ ReadReceipt lastLocalReadReceipt() const;
+
+ //! \brief Find the timeline item the local read receipt is at
+ //!
+ //! This is a shortcut for \code
+ //! room->findInTimeline(room->lastLocalReadReceipt().eventId);
+ //! \endcode
+ rev_iter_t localReadReceiptMarker() const;
+
+ //! \brief Get the latest event id marked as fully read
+ //!
+ //! This can be either the event id pointed to by the actual latest
+ //! m.fully_read event, or the latest event id marked locally as fully read
+ //! if markMessagesAsRead or markAllMessagesAsRead has been called and
+ //! the homeserver didn't return an updated m.fully_read event yet.
+ //! \sa markMessagesAsRead, markAllMessagesAsRead, fullyReadMarker
+ QString lastFullyReadEventId() const;
+
+ //! \brief Get the iterator to the latest timeline item marked as fully read
+ //!
+ //! This method calls findInTimeline on the result of lastFullyReadEventId.
+ //! If the fully read marker turns out to be outside the timeline (because
+ //! the event marked as fully read is too far back in the history) the
+ //! returned value will be equal to historyEdge.
+ //!
+ //! Be sure to read the caveats on iterators returned by findInTimeline.
+ //! \sa lastFullyReadEventId, findInTimeline
+ rev_iter_t fullyReadMarker() const;
+
+ //! \brief Get users whose latest read receipts point to the event
+ //!
+ //! This method is for cases when you need to show users who have read
+ //! an event. Calling it on inexistent or empty event id will return
+ //! an empty set.
+ //! \note The returned list may contain ids resolving to users that are
+ //! not loaded as room members yet (in particular, if members are not
+ //! yet lazy-loaded). For now this merely means that the user's
+ //! room-specific name and avatar will not be there; but generally
+ //! it's recommended to ensure that all room members are loaded
+ //! before operating on the result of this function.
+ //! \sa lastReadReceipt, allMembersLoaded
+ QSet<QString> userIdsAtEvent(const QString& eventId);
+
+ [[deprecated("Use userIdsAtEvent instead")]]
+ QSet<User*> usersAtEventId(const QString& eventId);
+
+ //! \brief Mark the event with uptoEventId as fully read
+ //!
+ //! Marks the event with the specified id as fully read locally and also
+ //! sends an update to m.fully_read account data to the server either
+ //! for this message or, if it's from the local user, for
+ //! the nearest non-local message before. uptoEventId must point to a known
+ //! event in the timeline; the method will do nothing if the event is behind
+ //! the current m.fully_read marker or is not loaded, to prevent
+ //! accidentally trying to move the marker back in the timeline.
+ //! \sa markAllMessagesAsRead, fullyReadMarker
+ Q_INVOKABLE void markMessagesAsRead(QString uptoEventId);
+
+ //! Check whether there are unread messages in the room
bool hasUnreadMessages() const;
/** Get the number of unread messages in the room
@@ -595,7 +709,12 @@ public Q_SLOTS:
void downloadFile(const QString& eventId, const QUrl& localFilename = {});
void cancelFileTransfer(const QString& id);
- /// Mark all messages in the room as read
+ //! \brief Set a given event as last read and post a read receipt on it
+ //!
+ //! Does nothing if the event is behind the current read receipt.
+ //! \sa lastReadReceipt, markMessagesAsRead, markAllMessagesAsRead
+ void setReadReceipt(const QString& atEventId);
+ //! Put the fully-read marker at the latest message in the room
void markAllMessagesAsRead();
/// Switch the room's version (aka upgrade)
@@ -705,7 +824,10 @@ Q_SIGNALS:
void firstDisplayedEventChanged();
void lastDisplayedEventChanged();
void lastReadEventChanged(Quotient::User* user);
+ void fullyReadMarkerMoved(QString fromEventId, QString toEventId);
+ //! \deprecated since 0.7 - use fullyReadMarkerMoved
void readMarkerMoved(QString fromEventId, QString toEventId);
+ //! \deprecated since 0.7 - use lastReadEventChanged
void readMarkerForUserMoved(Quotient::User* user, QString fromEventId,
QString toEventId);
void unreadMessagesChanged(Quotient::Room* room);
@@ -781,4 +903,5 @@ private:
};
} // namespace Quotient
Q_DECLARE_METATYPE(Quotient::FileTransferInfo)
+Q_DECLARE_METATYPE(Quotient::ReadReceipt)
Q_DECLARE_OPERATORS_FOR_FLAGS(Quotient::Room::Changes)
diff --git a/lib/syncdata.cpp b/lib/syncdata.cpp
index 4edc9564..e86d3100 100644
--- a/lib/syncdata.cpp
+++ b/lib/syncdata.cpp
@@ -103,7 +103,7 @@ SyncData::SyncData(const QString& cacheFileName)
{
QFileInfo cacheFileInfo { cacheFileName };
auto json = loadJson(cacheFileName);
- auto requiredVersion = std::get<0>(cacheVersion());
+ auto requiredVersion = MajorCacheVersion;
auto actualVersion =
json.value("cache_version"_ls).toObject().value("major"_ls).toInt();
if (actualVersion == requiredVersion)
@@ -128,6 +128,11 @@ Events&& SyncData::takeAccountData() { return std::move(accountData); }
Events&& SyncData::takeToDeviceEvents() { return std::move(toDeviceEvents); }
+std::pair<int, int> SyncData::cacheVersion()
+{
+ return { MajorCacheVersion, 1 };
+}
+
QJsonObject SyncData::loadJson(const QString& fileName)
{
QFile roomFile { fileName };
diff --git a/lib/syncdata.h b/lib/syncdata.h
index b0e31726..b869a541 100644
--- a/lib/syncdata.h
+++ b/lib/syncdata.h
@@ -87,7 +87,8 @@ public:
QStringList unresolvedRooms() const { return unresolvedRoomIds; }
- static std::pair<int, int> cacheVersion() { return { 11, 0 }; }
+ static constexpr int MajorCacheVersion = 11;
+ static std::pair<int, int> cacheVersion();
static QString fileNameForRoom(QString roomId);
private: