diff options
Diffstat (limited to 'lib/room.cpp')
-rw-r--r-- | lib/room.cpp | 859 |
1 files changed, 547 insertions, 312 deletions
diff --git a/lib/room.cpp b/lib/room.cpp index 7e0806a7..fac24e5e 100644 --- a/lib/room.cpp +++ b/lib/room.cpp @@ -15,6 +15,7 @@ #include "e2ee.h" #include "syncdata.h" #include "user.h" +#include "eventstats.h" // NB: since Qt 6, moc_room.cpp needs User fully defined #include "moc_room.cpp" @@ -54,7 +55,6 @@ #include <QtCore/QDir> #include <QtCore/QHash> -#include <QtCore/QMimeDatabase> #include <QtCore/QPointer> #include <QtCore/QRegularExpression> #include <QtCore/QStringBuilder> // for efficient string concats (operator%) @@ -117,19 +117,21 @@ public: QHash<QPair<QString, QString>, RelatedEvents> relations; QString displayname; Avatar avatar; - int highlightCount = 0; - int notificationCount = 0; + QHash<QString, Notification> notifications; + qsizetype serverHighlightCount = 0; + // Starting up with estimate event statistics as there's zero knowledge + // about the timeline. + EventStats partiallyReadStats {}, unreadStats {}; 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; @@ -253,39 +255,34 @@ public: // return EventT::content_type() // } - bool isEventNotable(const TimelineItem& ti) const - { - return !ti->isRedacted() && ti->senderId() != connection->userId() - && is<RoomMessageEvent>(*ti) - && ti.viewAs<RoomMessageEvent>()->replacedEvent().isEmpty(); - } - template <typename EventArrayT> Changes updateStateFrom(EventArrayT&& events) { - Changes changes = NoChange; + Changes changes {}; if (!events.empty()) { QElapsedTimer et; et.start(); for (auto&& eptr : events) { const auto& evt = *eptr; Q_ASSERT(evt.isStateEvent()); - auto change = q->processStateEvent(evt); - if (change != NoChange) { + if (auto change = q->processStateEvent(evt); change) { changes |= change; baseState[{ evt.matrixType(), evt.stateKey() }] = move(eptr); } } if (events.size() > 9 || et.nsecsElapsed() >= profilerMinNsecs()) qCDebug(PROFILER) - << "*** Room::Private::updateStateFrom():" << events.size() - << "event(s)," << et; + << "Updated" << q->objectName() << "room state from" + << events.size() << "event(s) in" << et; } return changes; } Changes addNewMessageEvents(RoomEvents&& events); void addHistoricalMessageEvents(RoomEvents&& events); + Changes updateStatsFromSyncData(const SyncRoomData &data, bool fromCache); + void postprocessChanges(Changes changes, bool saveState = true); + /** Move events into the timeline * * Insert events into the timeline, either new or historical. @@ -303,11 +300,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); + Changes setLastReadReceipt(const QString& userId, rev_iter_t newMarker, + ReadReceipt newReceipt = {}, + bool deferStatsUpdate = false); + Changes setFullyReadMarker(const QString &eventId); + Changes updateStats(const rev_iter_t& from, const rev_iter_t& to); + bool markMessagesAsRead(const rev_iter_t& upToMarker); void getAllMembers(); @@ -473,7 +471,7 @@ Room::Room(Connection* connection, QString id, JoinState initialJoinState) emit baseStateLoaded(); return this == r; // loadedRoomState fires only once per room }); - qCDebug(STATE) << "New" << initialJoinState << "Room:" << id; + qCDebug(STATE) << "New" << terse << initialJoinState << "Room:" << id; } Room::~Room() { delete d; } @@ -584,13 +582,13 @@ QImage Room::avatar(int width, int height) { if (!d->avatar.url().isEmpty()) return d->avatar.get(connection(), width, height, - [=] { emit avatarChanged(); }); + [this] { emit avatarChanged(); }); // Use the first (excluding self) user's avatar for direct chats const auto dcUsers = directChatUsers(); for (auto* u : dcUsers) if (u != localUser()) - return u->avatar(width, height, this, [=] { emit avatarChanged(); }); + return u->avatar(width, height, this, [this] { emit avatarChanged(); }); return {}; } @@ -624,156 +622,261 @@ void Room::setJoinState(JoinState state) if (state == oldState) return; d->joinState = state; - qCDebug(STATE) << "Room" << id() << "changed state: " << oldState + qCDebug(STATE) << "Room" << id() << "changed state: " << terse << oldState << "->" << state; - emit changed(Change::JoinStateChange); 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); - return Change::ReadMarkerChange; +Room::Changes Room::Private::setLastReadReceipt(const QString& userId, + rev_iter_t newMarker, + ReadReceipt newReceipt, + bool deferStatsUpdate) +{ + 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()) { + newMarker = rev_iter_t(eagerMarker); + qCDebug(EPHEMERAL) << "Auto-promoted read receipt for" << userId + << "to" << *newMarker; + } + // 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; + // Check that either the new marker is actually "newer" than the current one + // or, if both markers are at historyEdge(), event ids are different. + // NB: with reverse iterators, timeline history edge >= sync edge + if (prevEventId == newReceipt.eventId + || newMarker > q->findInTimeline(prevEventId)) + return Change::None; + + // Finally make the change + + Changes changes = Change::Other; + 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); + + { + auto dbg = qDebug(EPHEMERAL); // This trick needs qDebug, not qCDebug + dbg << "The new read receipt for" << userId << "is now at"; + if (newMarker == historyEdge()) + dbg << storedReceipt.eventId; + else + dbg << *newMarker; } - return Change::NoChange; + + // 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); + if (isLocalUser(member) && !deferStatsUpdate) { + if (unreadStats.updateOnMarkerMove(q, q->findInTimeline(prevEventId), + newMarker)) { + qCDebug(MESSAGES) + << "Updated unread event statistics in" << q->objectName() + << "after moving the local read receipt:" << unreadStats; + changes |= Change::UnreadStats; + } + Q_ASSERT(unreadStats.isValidFor(q, newMarker)); // post-check + } + emit q->lastReadEventChanged(member); + // TODO: remove in 0.8 + if (!isLocalUser(member)) + emit q->readMarkerForUserMoved(member, prevEventId, + storedReceipt.eventId); + return changes; } -void Room::Private::updateUnreadCount(const rev_iter_t& from, - const rev_iter_t& to) +Room::Changes Room::Private::updateStats(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; + const auto fullyReadMarker = q->fullyReadMarker(); + auto readReceiptMarker = q->localReadReceiptMarker(); + Changes changes = Change::None; + // Correct the read receipt to never be behind the fully read marker + if (readReceiptMarker > fullyReadMarker + && setLastReadReceipt(connection->userId(), fullyReadMarker, {}, true)) { + changes |= Change::Other; + readReceiptMarker = q->localReadReceiptMarker(); + qCInfo(MESSAGES) << "The local m.read receipt was behind m.fully_read " + "marker - it's now corrected to be at index" + << readReceiptMarker->index(); + } + + if (fullyReadMarker < from) + return Change::None; // 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 case when the id in the last fully read marker or the local read + // receipt refers to an event that has just arrived. In this case either + // one (unreadStats) or both statistics should be recalculated to get + // an exact number instead of an estimation (see documentation on + // EventStats::isEstimate). For the same reason (switching from the + // estimate to the exact number) this branch forces returning + // Change::UnreadStats and also possibly Change::PartiallyReadStats, even if + // the estimation luckily matched the exact result. + if (readReceiptMarker < to || changes /*i.e. read receipt was corrected*/) { + unreadStats = EventStats::fromMarker(q, readReceiptMarker); + Q_ASSERT(!unreadStats.isEstimate); + qCDebug(MESSAGES).nospace() << "Recalculated unread event statistics in" + << q->objectName() << ": " << unreadStats; + changes |= Change::UnreadStats; + if (fullyReadMarker < to) { + // Add up to unreadStats instead of counting same events again + partiallyReadStats = EventStats::fromRange(q, readReceiptMarker, + q->fullyReadMarker(), + unreadStats); + Q_ASSERT(!partiallyReadStats.isEstimate); + + qCDebug(MESSAGES).nospace() + << "Recalculated partially read event statistics in " + << q->objectName() << ": " << partiallyReadStats; + return changes | Change::PartiallyReadStats; + } } - Q_ASSERT(to <= readMarker); - - QElapsedTimer et; - et.start(); - const auto newUnreadMessages = - 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); - } -} - -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()); - - const auto prevMarker = q->readMarker(u); - if (!force && prevMarker <= newMarker) // Remember, we deal with reverse - // iterators - return Change::NoChange; - - Q_ASSERT(newMarker < historyEdge()); - - // 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(); - }); + // As of here, at least the fully read marker (but maybe also read receipt) + // points to somewhere beyond the "oldest" message from the arrived batch - + // add up newly arrived messages to the current stats, instead of a complete + // recalculation. + Q_ASSERT(fullyReadMarker >= to); - 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; + const auto newStats = EventStats::fromRange(q, from, to); + Q_ASSERT(!newStats.isEstimate); + if (newStats.empty()) + return changes; - // See https://github.com/quotient-im/libQuotient/wiki/unread_count - if (unreadMessages == 0) - unreadMessages = -1; + const auto doAddStats = [this, &changes, newStats](EventStats& s, + const rev_iter_t& marker, + Change c) { + s.notableCount += newStats.notableCount; + s.highlightCount += newStats.highlightCount; + if (!s.isEstimate) + s.isEstimate = marker == historyEdge(); + changes |= c; + }; - 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; - } + doAddStats(partiallyReadStats, fullyReadMarker, Change::PartiallyReadStats); + if (readReceiptMarker >= to) { + // readReceiptMarker < to branch shouldn't have been entered + Q_ASSERT(!changes.testFlag(Change::UnreadStats)); + doAddStats(unreadStats, readReceiptMarker, Change::UnreadStats); } + qCDebug(MESSAGES) << "Room" << q->objectName() << "has gained" << newStats + << "notable/highlighted event(s); total statistics:" + << partiallyReadStats << "since the fully read marker," + << unreadStats << "since read receipt"; + + // Check invariants + Q_ASSERT(partiallyReadStats.isValidFor(q, fullyReadMarker)); + Q_ASSERT(unreadStats.isValidFor(q, readReceiptMarker)); return changes; } -Room::Changes Room::Private::markMessagesAsRead(rev_iter_t upToMarker) +Room::Changes Room::Private::setFullyReadMarker(const QString& eventId) { - const auto prevMarker = q->readMarker(); - auto changes = promoteReadMarker(q->localUser(), upToMarker); - if (prevMarker != upToMarker) - qCDebug(MESSAGES) << "Marked messages as read until" << *q->readMarker(); + if (fullyReadUntilEventId == eventId) + return Change::None; - // 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"), - QUrl::toPercentEncoding( - (*upToMarker)->id())); - break; + const auto prevReadMarker = q->fullyReadMarker(); + const auto newReadMarker = q->findInTimeline(eventId); + if (newReadMarker > prevReadMarker) + return Change::None; + + const auto prevFullyReadId = std::exchange(fullyReadUntilEventId, eventId); + qCDebug(MESSAGES) << "Fully read marker in" << q->objectName() // + << "set to" << fullyReadUntilEventId; + + QT_IGNORE_DEPRECATIONS(Changes changes = Change::ReadMarker|Change::Other;) + if (const auto rm = q->fullyReadMarker(); rm != historyEdge()) { + // Pull read receipt if it's behind, and update statistics + changes |= setLastReadReceipt(connection->userId(), rm); + if (partiallyReadStats.updateOnMarkerMove(q, prevReadMarker, rm)) { + changes |= Change::PartiallyReadStats; + qCDebug(MESSAGES) + << "Updated partially read event statistics in" + << q->objectName() + << "after moving m.fully_read marker: " << partiallyReadStats; } + Q_ASSERT(partiallyReadStats.isValidFor(q, rm)); // post-check } + emit q->fullyReadMarkerMoved(prevFullyReadId, fullyReadUntilEventId); + // TODO: Remove in 0.8 + emit q->readMarkerMoved(prevFullyReadId, fullyReadUntilEventId); return changes; } -void Room::markMessagesAsRead(QString uptoEventId) +void Room::setReadReceipt(const QString& atEventId) +{ + if (const auto changes = d->setLastReadReceipt(localUser()->id(), + historyEdge(), + { atEventId })) { + connection()->callApi<PostReceiptJob>(BackgroundRequest, id(), + QStringLiteral("m.read"), + QUrl::toPercentEncoding(atEventId)); + d->postprocessChanges(changes); + } else + qCDebug(EPHEMERAL) << "The new read receipt for" << localUser()->id() + << "in" << objectName() + << "is at or behind the old one, skipping"; +} + +bool Room::Private::markMessagesAsRead(const rev_iter_t &upToMarker) +{ + if (upToMarker == q->historyEdge()) + qCWarning(MESSAGES) << "Cannot mark an unknown event in" + << q->objectName() << "as fully read"; + else if (const auto changes = setFullyReadMarker(upToMarker->event()->id())) { + // The assumption below is that if a read receipt was sent on a newer + // event, the homeserver will keep it there instead of reverting to + // m.fully_read + connection->callApi<SetReadMarkerJob>(BackgroundRequest, id, + fullyReadUntilEventId, + fullyReadUntilEventId); + postprocessChanges(changes); + return true; + } else + qCDebug(MESSAGES) << "Event" << *upToMarker << "in" << q->objectName() + << "is behind the current fully read marker at" + << *q->fullyReadMarker() + << "- won't move fully read marker back in timeline"; + return false; +} + +void Room::markMessagesAsRead(const QString& uptoEventId) { d->markMessagesAsRead(findInTimeline(uptoEventId)); } void Room::markAllMessagesAsRead() { - if (!d->timeline.empty()) - d->markMessagesAsRead(d->timeline.crbegin()); + d->markMessagesAsRead(d->timeline.crbegin()); } bool Room::canSwitchVersions() const @@ -790,9 +893,40 @@ bool Room::canSwitchVersions() const return true; } -bool Room::hasUnreadMessages() const { return unreadCount() >= 0; } +bool Room::isEventNotable(const TimelineItem &ti) const +{ + const auto& evt = *ti; + const auto* rme = ti.viewAs<RoomMessageEvent>(); + return !evt.isRedacted() + && (is<RoomTopicEvent>(evt) || is<RoomNameEvent>(evt) + || is<RoomAvatarEvent>(evt) || is<RoomTombstoneEvent>(evt) + || (rme && rme->msgtype() != MessageEventType::Notice + && rme->replacedEvent().isEmpty())) + && evt.senderId() != localUser()->id(); +} -int Room::unreadCount() const { return d->unreadMessages; } +Notification Room::notificationFor(const TimelineItem &ti) const +{ + return d->notifications.value(ti->id()); +} + +Notification Room::checkForNotifications(const TimelineItem &ti) +{ + return { Notification::None }; +} + +bool Room::hasUnreadMessages() const { return !d->partiallyReadStats.empty(); } + +int countFromStats(const EventStats& s) +{ + return s.empty() ? -1 : int(s.notableCount); +} + +int Room::unreadCount() const { return countFromStats(partiallyReadStats()); } + +EventStats Room::partiallyReadStats() const { return d->partiallyReadStats; } + +EventStats Room::unreadStats() const { return d->unreadStats; } Room::rev_iter_t Room::historyEdge() const { return d->historyEdge(); } @@ -868,7 +1002,7 @@ void Room::Private::getAllMembers() allMembersJob = connection->callApi<GetMembersByRoomJob>( id, connection->nextBatchToken(), "join"); auto nextIndex = timeline.empty() ? 0 : timeline.back().index() + 1; - connect(allMembersJob, &BaseJob::success, q, [=] { + connect(allMembersJob, &BaseJob::success, q, [this, nextIndex] { Q_ASSERT(timeline.empty() || nextIndex <= q->maxTimelineIndex() + 1); auto roomChanges = updateStateFrom(allMembersJob->chunk()); // Replay member events that arrived after the point for which @@ -878,8 +1012,7 @@ void Room::Private::getAllMembers() it != syncEdge(); ++it) if (is<RoomMemberEvent>(**it)) roomChanges |= q->processStateEvent(**it); - if (roomChanges & MembersChange) - emit q->memberListChanged(); + postprocessChanges(roomChanges); emit q->allMembersLoaded(); }); } @@ -893,11 +1026,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; } @@ -958,38 +1088,70 @@ 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); } -int Room::notificationCount() const { return d->notificationCount; } +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; +} + +qsizetype Room::notificationCount() const +{ + return d->unreadStats.notableCount; +} void Room::resetNotificationCount() { - if (d->notificationCount == 0) + if (d->unreadStats.notableCount == 0) return; - d->notificationCount = 0; + d->unreadStats.notableCount = 0; emit notificationCountChanged(); } -int Room::highlightCount() const { return d->highlightCount; } +qsizetype Room::highlightCount() const { return d->serverHighlightCount; } void Room::resetHighlightCount() { - if (d->highlightCount == 0) + if (d->serverHighlightCount == 0) return; - d->highlightCount = 0; + d->serverHighlightCount = 0; emit highlightCountChanged(); } @@ -1378,11 +1540,10 @@ GetRoomEventsJob* Room::eventsHistoryJob() const { return d->eventsHistoryJob; } Room::Changes Room::Private::setSummary(RoomSummary&& newSummary) { if (!summary.merge(newSummary)) - return Change::NoChange; + return Change::None; qCDebug(STATE).nospace().noquote() << "Updated room summary for " << q->objectName() << ": " << summary; - emit q->memberListChanged(); - return Change::SummaryChange; + return Change::Summary; } void Room::Private::insertMemberIntoMap(User* u) @@ -1460,7 +1621,8 @@ void Room::Private::removeMemberFromMap(User* u) inline auto makeErrorStr(const Event& e, QByteArray msg) { - return msg.append("; event dump follows:\n").append(e.originalJson()); + return msg.append("; event dump follows:\n") + .append(QJsonDocument(e.fullJson()).toJson()); } Room::Timeline::size_type @@ -1486,11 +1648,12 @@ Room::Private::moveEventsToTimeline(RoomEventsRange events, !eventsIndex.contains(eId), __FUNCTION__, makeErrorStr(*e, "Event is already in the timeline; " "incoming events were not properly deduplicated")); - if (placement == Older) - timeline.emplace_front(move(e), --index); - else - timeline.emplace_back(move(e), ++index); + const auto& ti = placement == Older + ? timeline.emplace_front(move(e), --index) + : timeline.emplace_back(move(e), ++index); eventsIndex.insert(eId, index); + if (auto n = q->checkForNotifications(ti); n.type != Notification::None) + notifications.insert(e->id(), n); Q_ASSERT(q->findInTimeline(eId)->event()->id() == eId); } const auto insertedSize = (index - baseIndex) * placement; @@ -1565,61 +1728,132 @@ QUrl Room::memberAvatarUrl(const QString &mxId) const : QUrl(); } +Room::Changes Room::Private::updateStatsFromSyncData(const SyncRoomData& data, + bool fromCache) +{ + Changes changes {}; + if (fromCache) { + // Initial load of cached statistics + partiallyReadStats = + EventStats::fromCachedCounters(data.partiallyReadCount); + unreadStats = EventStats::fromCachedCounters(data.unreadCount, + data.highlightCount); + // Migrate from lib 0.6: -1 in the old unread counter overrides 0 + // (which loads to an estimate) in notification_count. Next caching will + // save -1 in both places, completing the migration. + if (data.unreadCount == 0 && data.partiallyReadCount == -1) + unreadStats.isEstimate = false; + changes |= Change::PartiallyReadStats | Change::UnreadStats; + qCDebug(MESSAGES) << "Loaded" << q->objectName() + << "event statistics from cache:" << partiallyReadStats + << "since m.fully_read," << unreadStats + << "since m.read"; + } else if (timeline.empty()) { + // In absence of actual events use statistics from the homeserver + if (merge(unreadStats.notableCount, data.unreadCount)) + changes |= Change::PartiallyReadStats; + if (merge(unreadStats.highlightCount, data.highlightCount)) + changes |= Change::UnreadStats; + unreadStats.isEstimate = !data.unreadCount.has_value() + || *data.unreadCount > 0; + qCDebug(MESSAGES) + << "Using server-side unread event statistics while the" + << q->objectName() << "timeline is empty:" << unreadStats; + } + bool correctedStats = false; + if (unreadStats.highlightCount > partiallyReadStats.highlightCount) { + correctedStats = true; + partiallyReadStats.highlightCount = unreadStats.highlightCount; + partiallyReadStats.isEstimate |= unreadStats.isEstimate; + } + if (unreadStats.notableCount > partiallyReadStats.notableCount) { + correctedStats = true; + partiallyReadStats.notableCount = unreadStats.notableCount; + partiallyReadStats.isEstimate |= unreadStats.isEstimate; + } + if (!unreadStats.isEstimate && partiallyReadStats.isEstimate) { + correctedStats = true; + partiallyReadStats.isEstimate = true; + } + if (correctedStats) + qCDebug(MESSAGES) << "Partially read event statistics in" + << q->objectName() << "were adjusted to" + << partiallyReadStats + << "to be consistent with the m.read receipt"; + Q_ASSERT(partiallyReadStats.isValidFor(q, q->fullyReadMarker())); + Q_ASSERT(unreadStats.isValidFor(q, q->localReadReceiptMarker())); + + // TODO: Once the library learns to count highlights, drop + // serverHighlightCount and only use the server-side counter when + // the timeline is empty (see the code above). + if (merge(serverHighlightCount, data.highlightCount)) { + qCDebug(MESSAGES) << "Updated highlights number in" << q->objectName() + << "to" << serverHighlightCount; + changes |= Change::Highlights; + } + return changes; +} + void Room::updateData(SyncRoomData&& data, bool fromCache) { if (d->prevBatch.isEmpty()) d->prevBatch = data.timelinePrevBatch; setJoinState(data.joinState); - Changes roomChanges = Change::NoChange; - QElapsedTimer et; - et.start(); + Changes roomChanges {}; + // The order of calculation is important - don't merge the lines! + roomChanges |= d->updateStateFrom(data.state); + roomChanges |= d->setSummary(move(data.summary)); + roomChanges |= d->addNewMessageEvents(move(data.timeline)); + + for (auto&& ephemeralEvent : data.ephemeral) + roomChanges |= processEphemeralEvent(move(ephemeralEvent)); + for (auto&& event : data.accountData) roomChanges |= processAccountDataEvent(move(event)); - roomChanges |= d->updateStateFrom(data.state); + roomChanges |= d->updateStatsFromSyncData(data, fromCache); - if (!data.timeline.empty()) { - et.restart(); - roomChanges |= d->addNewMessageEvents(move(data.timeline)); - if (data.timeline.size() > 9 || et.nsecsElapsed() >= profilerMinNsecs()) - qCDebug(PROFILER) - << "*** Room::addNewMessageEvents():" << data.timeline.size() - << "event(s)," << et; - } - if (roomChanges & TopicChange) + if (roomChanges & Change::Topic) emit topicChanged(); - if (roomChanges & (NameChange | AliasesChange)) + if (roomChanges & (Change::Name | Change::Aliases)) emit namesChanged(this); - if (roomChanges & MembersChange) - emit memberListChanged(); + d->postprocessChanges(roomChanges, !fromCache); +} - roomChanges |= d->setSummary(move(data.summary)); +void Room::Private::postprocessChanges(Changes changes, bool saveState) +{ + if (!changes) + return; - for (auto&& ephemeralEvent : data.ephemeral) - roomChanges |= processEphemeralEvent(move(ephemeralEvent)); + if (changes & Change::Members) + emit q->memberListChanged(); - // See https://github.com/quotient-im/libQuotient/wiki/unread_count - if (merge(d->unreadMessages, data.unreadCount)) { - qCDebug(MESSAGES) << "Loaded unread_count:" << *data.unreadCount // - << "in" << objectName(); - emit unreadMessagesChanged(this); + if (changes + & (Change::Name | Change::Aliases | Change::Members | Change::Summary)) + updateDisplayname(); + + if (changes & Change::PartiallyReadStats) { + emit q->unreadMessagesChanged(q); // TODO: remove in 0.8 + emit q->partiallyReadStatsChanged(); } - if (merge(d->highlightCount, data.highlightCount)) - emit highlightCountChanged(); + if (changes & Change::UnreadStats) + emit q->unreadStatsChanged(); - if (merge(d->notificationCount, data.notificationCount)) - emit notificationCountChanged(); + if (changes & Change::Highlights) + emit q->highlightCountChanged(); - if (roomChanges != Change::NoChange) { - d->updateDisplayname(); - emit changed(roomChanges); - if (!fromCache) - connection()->saveRoomState(this); - } + qCDebug(MAIN) << terse << changes << "= hex" << +#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) + Qt:: +#endif + hex << uint(changes) << "in" << q->objectName(); + emit q->changed(changes); + if (saveState) + connection->saveRoomState(q); } RoomEvent* Room::Private::addAsPending(RoomEventPtr&& event) @@ -1991,7 +2225,7 @@ void Room::Private::getPreviousContent(int limit, const QString &filter) eventsHistoryJob = connection->callApi<GetRoomEventsJob>(id, prevBatch, "b", "", limit, filter); emit q->eventsHistoryJobChanged(); - connect(eventsHistoryJob, &BaseJob::success, q, [=] { + connect(eventsHistoryJob, &BaseJob::success, q, [this] { prevBatch = eventsHistoryJob->end(); addHistoricalMessageEvents(eventsHistoryJob->chunk()); }); @@ -2162,7 +2396,7 @@ void Room::Private::dropDuplicateEvents(RoomEvents& events) const RoomEventPtr makeRedacted(const RoomEvent& target, const RedactionEvent& redaction) { - auto originalJson = target.originalJsonObject(); + auto originalJson = target.fullJson(); // clang-format off static const QStringList keepKeys { EventIdKey, TypeKey, RoomIdKey, SenderKey, StateKeyKey, @@ -2208,7 +2442,7 @@ RoomEventPtr makeRedacted(const RoomEvent& target, originalJson.insert(ContentKey, content); } auto unsignedData = originalJson.take(UnsignedKeyL).toObject(); - unsignedData[RedactedCauseKeyL] = redaction.originalJsonObject(); + unsignedData[RedactedCauseKeyL] = redaction.fullJson(); originalJson.insert(QStringLiteral("unsigned"), unsignedData); return loadEvent<RoomEvent>(originalJson); @@ -2278,7 +2512,7 @@ RoomEventPtr makeReplaced(const RoomEvent& target, if (!targetReply.empty()) { newContent["m.relates_to"] = targetReply; } - auto originalJson = target.originalJsonObject(); + auto originalJson = target.fullJson(); originalJson[ContentKeyL] = newContent; auto unsignedData = originalJson.take(UnsignedKeyL).toObject(); @@ -2339,8 +2573,10 @@ Room::Changes Room::Private::addNewMessageEvents(RoomEvents&& events) { dropDuplicateEvents(events); if (events.empty()) - return Change::NoChange; + return Change::None; + QElapsedTimer et; + et.start(); { // Pre-process redactions and edits so that events that get // redacted/replaced in the same batch landed in the timeline already @@ -2390,7 +2626,7 @@ Room::Changes Room::Private::addNewMessageEvents(RoomEvents&& events) // clients historically expect. This may eventually change though if we // postulate that the current state is only current between syncs but not // within a sync. - Changes roomChanges = Change::NoChange; + Changes roomChanges {}; for (const auto& eptr : events) roomChanges |= q->processStateEvent(*eptr); @@ -2463,28 +2699,22 @@ 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); - } - } + roomChanges |= updateStats(timeline.crbegin(), rev_iter_t(from)); - updateUnreadCount(timeline.crbegin(), rev_iter_t(from)); - roomChanges |= Change::UnreadNotifsChange; + // 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. + const auto& firstWriterId = (*from)->senderId(); + if (firstWriterId == connection->userId() + && q->fullyReadMarker().base() == from) + roomChanges |= + setFullyReadMarker(q->lastReadReceipt(firstWriterId).eventId); } Q_ASSERT(timeline.size() == timelineSize + totalInserted); + if (totalInserted > 9 || et.nsecsElapsed() >= profilerMinNsecs()) + qCDebug(PROFILER) << "Added" << totalInserted << "new event(s) to" + << q->objectName() << "in" << et; return roomChanges; } @@ -2498,6 +2728,7 @@ void Room::Private::addHistoricalMessageEvents(RoomEvents&& events) if (events.empty()) return; + Changes changes {}; // In case of lazy-loading new members may be loaded with historical // messages. Also, the cache doesn't store events with empty content; // so when such events show up in the timeline they should be properly @@ -2506,7 +2737,7 @@ void Room::Private::addHistoricalMessageEvents(RoomEvents&& events) const auto& e = *eptr; if (e.isStateEvent() && !currentState.contains({ e.matrixType(), e.stateKey() })) { - q->processStateEvent(e); + changes |= q->processStateEvent(e); } } @@ -2526,19 +2757,20 @@ void Room::Private::addHistoricalMessageEvents(RoomEvents&& events) emit q->updatedEvent(relation.eventId); } } - if (from <= q->readMarker()) - updateUnreadCount(from, historyEdge()); - Q_ASSERT(timeline.size() == timelineSize + insertedSize); if (insertedSize > 9 || et.nsecsElapsed() >= profilerMinNsecs()) - qCDebug(PROFILER) << "*** Room::addHistoricalMessageEvents():" - << insertedSize << "event(s)," << et; + qCDebug(PROFILER) << "Added" << insertedSize << "historical event(s) to" + << q->objectName() << "in" << et; + + changes |= updateStats(from, historyEdge()); + if (changes) + postprocessChanges(changes); } Room::Changes Room::processStateEvent(const RoomEvent& e) { if (!e.isStateEvent()) - return NoChange; + return Change::None; // Find a value (create an empty one if necessary) and get a reference // to it. Can't use getCurrentState<>() because it (creates and) returns @@ -2625,8 +2857,11 @@ Room::Changes Room::processStateEvent(const RoomEvent& e) } , true); // By default, go forward with the state change // clang-format on - if (!proceed) - return NoChange; + if (!proceed) { + if (!curStateEvent) // Remove the empty placeholder if one was created + d->currentState.remove({ e.matrixType(), e.stateKey() }); + return Change::None; + } // Change the state const auto* const oldStateEvent = @@ -2644,7 +2879,7 @@ Room::Changes Room::processStateEvent(const RoomEvent& e) // clang-format off const auto result = visit(e , [] (const RoomNameEvent&) { - return NameChange; + return Change::Name; } , [this, oldStateEvent] (const RoomCanonicalAliasEvent& cae) { // clang-format on @@ -2663,16 +2898,16 @@ Room::Changes Room::processStateEvent(const RoomEvent& e) newAliases.push_front(cae.alias()); connection()->updateRoomAliases(id(), previousAltAliases, newAliases); - return AliasesChange; + return Change::Aliases; // clang-format off } , [] (const RoomTopicEvent&) { - return TopicChange; + return Change::Topic; } , [this] (const RoomAvatarEvent& evt) { if (d->avatar.updateUrl(evt.url())) emit avatarChanged(); - return AvatarChange; + return Change::Avatar; } , [this,oldStateEvent] (const RoomMemberEvent& evt) { // clang-format on @@ -2711,14 +2946,14 @@ Room::Changes Room::processStateEvent(const RoomEvent& e) case Membership::Undefined: qCWarning(MEMBERS) << "Ignored undefined membership type"; } - return MembersChange; + return Change::Members; // clang-format off } , [this] (const EncryptionEvent&) { // As encryption can only be switched on once, emit the signal here // instead of aggregating and emitting in updateData() emit encryption(); - return OtherChange; + return Change::Other; } , [this] (const RoomTombstoneEvent& evt) { const auto successorId = evt.successorRoomId(); @@ -2734,29 +2969,31 @@ Room::Changes Room::processStateEvent(const RoomEvent& e) return true; }); - return OtherChange; + return Change::Other; // clang-format off } - , OtherChange); + , Change::Other); // clang-format on - Q_ASSERT(result != NoChange); + Q_ASSERT(result != Change::None); return result; } Room::Changes Room::processEphemeralEvent(EventPtr&& event) { - Changes changes = NoChange; + Changes changes {}; QElapsedTimer et; 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)); if (evt->users().size() > 3 || et.nsecsElapsed() >= profilerMinNsecs()) - qCDebug(PROFILER) << "*** Room::processEphemeralEvent(typing):" - << evt->users().size() << "users," << et; + qCDebug(PROFILER) + << "Processing typing events from" << evt->users().size() + << "user(s) in" << objectName() << "took" << et; emit typingChanged(); } if (auto* evt = eventCast<ReceiptEvent>(event)) { @@ -2764,68 +3001,54 @@ 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" << p.evtId + << "is not found; saving read receipt(s) 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, &changes, &newMarker, &evtId = p.evtId](const auto& r) { + const auto change = + d->setLastReadReceipt(r.userId, newMarker, + { evtId, r.timestamp }); + changes |= change; + return change & Change::Any; + }); + + 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()) - qCDebug(PROFILER) - << "*** Room::processEphemeralEvent(receipts):" - << eventsWithReceipts.size() << "event(s) with" - << totalReceipts << "receipt(s)," << et; + qCDebug(PROFILER) << "Processing" << totalReceipts + << "receipt(s) on" << eventsWithReceipts.size() + << "event(s) in" << objectName() << "took" << et; } return changes; } Room::Changes Room::processAccountDataEvent(EventPtr&& event) { - Changes changes = NoChange; + Changes changes {}; if (auto* evt = eventCast<TagEvent>(event)) { d->setTags(evt->tags()); - changes |= Change::TagsChange; + changes |= Change::Tags; } - 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 @@ -2836,7 +3059,10 @@ Room::Changes Room::processAccountDataEvent(EventPtr&& event) qCDebug(STATE) << "Updated account data of type" << currentData->matrixType(); emit accountDataChanged(currentData->matrixType()); - changes |= Change::AccountDataChange; + // TODO: Drop AccountDataChange in 0.8 + // NB: GCC (at least 10) only accepts QT_IGNORE_DEPRECATIONS around + // a statement, not within a statement + QT_IGNORE_DEPRECATIONS(changes |= Change::AccountData | Change::Other;) } return changes; } @@ -3006,19 +3232,28 @@ QJsonObject Room::Private::toJson() const { QStringLiteral("events"), accountDataEvents } }); } - QJsonObject unreadNotifObj { { SyncRoomData::UnreadCountKey, - unreadMessages } }; - - if (highlightCount > 0) - unreadNotifObj.insert(QStringLiteral("highlight_count"), highlightCount); - if (notificationCount > 0) - unreadNotifObj.insert(QStringLiteral("notification_count"), - notificationCount); - - result.insert(QStringLiteral("unread_notifications"), unreadNotifObj); + 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() } } }); + } + + result.insert(UnreadNotificationsKey, + QJsonObject { { PartiallyReadCountKey, + countFromStats(partiallyReadStats) }, + { HighlightCountKey, serverHighlightCount } }); + result.insert(NewUnreadCountKey, countFromStats(unreadStats)); if (et.elapsed() > 30) - qCDebug(PROFILER) << "Room::toJson() for" << displayname << "took" << et; + qCDebug(PROFILER) << "Room::toJson() for" << q->objectName() << "took" + << et; return result; } |