diff --git a/src/client/actions/ephemeral.cpp b/src/client/actions/ephemeral.cpp --- a/src/client/actions/ephemeral.cpp +++ b/src/client/actions/ephemeral.cpp @@ -50,7 +50,13 @@ m.addJob(std::move(job)); - return { std::move(m), lager::noop }; + auto userId = m.userId; + auto [next, _] = ClientModel::update(std::move(m), UpdateRoomAction{ + a.roomId, + UpdateLocalReadMarkerAction{a.eventId, userId}, + }); + + return { std::move(next), lager::noop }; } ClientResult processResponse(ClientModel m, PostReceiptResponse r) diff --git a/src/client/room/room-model.hpp b/src/client/room/room-model.hpp --- a/src/client/room/room-model.hpp +++ b/src/client/room/room-model.hpp @@ -166,6 +166,13 @@ std::string myUserId; }; + /// Update the local read marker, removing any read notifications before it. + struct UpdateLocalReadMarkerAction + { + std::string localReadMarker; + std::string myUserId; + }; + inline bool operator==(const PendingRoomKeyEvent &a, const PendingRoomKeyEvent &b) { return a.txnId == b.txnId && a.messages == b.messages; @@ -340,7 +347,8 @@ UpdateJoinedMemberCountAction, UpdateInvitedMemberCountAction, AddLocalNotificationsAction, - RemoveReadLocalNotificationsAction + RemoveReadLocalNotificationsAction, + UpdateLocalReadMarkerAction >; static RoomModel update(RoomModel r, Action a); diff --git a/src/client/room/room-model.cpp b/src/client/room/room-model.cpp --- a/src/client/room/room-model.cpp +++ b/src/client/room/room-model.cpp @@ -20,6 +20,21 @@ inline const auto receiptTypes = immer::flex_vector<std::string>{"m.read", "m.read.private"}; +template<class Func> +static std::string getMaxInTimeline(std::string a, std::string b, Func sortKey) +{ + if (a.empty() && b.empty()) { + return std::string(); + } else { + // for an unexisting event, event id is empty and timestamp is 0 + // for an existing event, event id is not empty and timestamp >= 0 + // so this handles all cases even when we do not have the corresponding event + return std::max(a, b, [=](const std::string &x, const std::string &y) { + return sortKey(x) < sortKey(y); + }); + } +} + namespace Kazv { PendingRoomKeyEvent makePendingRoomKeyEventV0(std::string txnId, Event event, immer::map<std::string, immer::flex_vector<std::string>> devices) @@ -328,7 +343,11 @@ return r; }, [&](AddLocalNotificationsAction a) { - auto readReceiptForCurrentUser = r.readReceipts.count(a.myUserId) ? r.readReceipts[a.myUserId].eventId : ""s; + auto k = [r](const auto &id) { + return sortKeyForTimelineEvent(r.messages[id]); + }; + + auto readReceiptForCurrentUser = getMaxInTimeline(r.readReceipts[a.myUserId].eventId, r.localReadMarker, k); auto newEventIds = intoImmer( immer::flex_vector<std::string>{}, @@ -336,10 +355,6 @@ a.newEvents ); - auto k = [r](const auto &id) { - return sortKeyForTimelineEvent(r.messages[id]); - }; - auto needToAddPredicate = [a, r, k, readReceiptForCurrentUser](const auto &eid) { if (!readReceiptForCurrentUser.empty() && k(readReceiptForCurrentUser) >= k(eid)) { @@ -355,12 +370,6 @@ return r; }, [&](RemoveReadLocalNotificationsAction a) { - if (!r.readReceipts.count(a.myUserId)) { - return r; - } - - auto rr = r.readReceipts[a.myUserId].eventId; - auto k = [r](const auto &id) { return sortKeyForTimelineEvent(r.messages[id]); }; @@ -368,6 +377,15 @@ return k(a) < k(b); }; + auto rr = getMaxInTimeline( + r.readReceipts[a.myUserId].eventId, + r.localReadMarker, + k + ); + if (rr.empty()) { + return r; + } + auto it = std::upper_bound( r.unreadNotificationEventIds.begin(), r.unreadNotificationEventIds.end(), @@ -385,6 +403,11 @@ r.unreadNotificationEventIds = std::move(r.unreadNotificationEventIds).erase(0, it.index()); } return r; + }, + [&](UpdateLocalReadMarkerAction a) { + r.localReadMarker = a.localReadMarker; + auto next = RoomModel::update(std::move(r), RemoveReadLocalNotificationsAction{a.myUserId}); + return next; } ); } diff --git a/src/client/room/room.hpp b/src/client/room/room.hpp --- a/src/client/room/room.hpp +++ b/src/client/room/room.hpp @@ -340,6 +340,13 @@ /* lager::reader<bool> */ KAZV_WRAP_ATTR(RoomModel, roomCursor(), membersFullyLoaded); + /** + * Get the local read marker in this room. + * + * @return a lager::reader of a std::string of the local read marker. + */ + auto localReadMarker() const -> lager::reader<std::string>; + /** * Get the ids of the heroes of the room. * diff --git a/src/client/room/room.cpp b/src/client/room/room.cpp --- a/src/client/room/room.cpp +++ b/src/client/room/room.cpp @@ -278,6 +278,12 @@ return roomCursor()[&RoomModel::encrypted]; } + auto Room::localReadMarker() const + -> lager::reader<std::string> + { + return roomCursor()[&RoomModel::localReadMarker]; + } + auto Room::heroIds() const -> lager::reader<immer::flex_vector<std::string>> { diff --git a/src/tests/client/room/read-receipt-test.cpp b/src/tests/client/room/read-receipt-test.cpp --- a/src/tests/client/room/read-receipt-test.cpp +++ b/src/tests/client/room/read-receipt-test.cpp @@ -163,6 +163,25 @@ REQUIRE(readers2 == expected2); } +TEST_CASE("localReadMarker", "[client][room][receipt]") +{ + boost::asio::io_context io; + AsioPromiseHandler ph{io.get_executor()}; + + auto tl = EventList{ + makeEvent(), + }; + auto room = makeRoom(withRoomTimeline(tl)); + room.localReadMarker = tl[0].id(); + auto model = makeClient(withRoom(room)); + auto store = createTestClientStoreFrom(model, ph); + auto client = Client(store.reader().map([](auto c) { return SdkModel{c}; }), + store, + std::nullopt); + auto r = client.room(room.roomId); + REQUIRE(r.localReadMarker().get() == tl[0].id()); +} + TEST_CASE("Posting receipts", "[client][room][receipt]") { auto r = makeRoom(); @@ -217,6 +236,25 @@ }); } +static auto pushRules = PushRulesDesc(Event(R"({ + "type": "m.push_rules", + "content": { + "global": { + "override": [{ + "rule_id": "moe.kazv.mxc.some-rule", + "default": true, + "enabled": true, + "conditions": [{ + "kind": "event_match", + "key": "type", + "pattern": "moe.kazv.mxc.test-event" + }], + "actions": ["notify"] + }] + } + } +})"_json)); + TEST_CASE("AddLocalNotificationsAction", "[client][room][receipt]") { auto myUserId = "@mew:example.com"s; @@ -243,24 +281,7 @@ WHEN("push rules notifies some") { auto next = RoomModel::update(r, AddLocalNotificationsAction{ newEvents, - PushRulesDesc(Event(R"({ - "type": "m.push_rules", - "content": { - "global": { - "override": [{ - "rule_id": "moe.kazv.mxc.some-rule", - "default": true, - "enabled": true, - "conditions": [{ - "kind": "event_match", - "key": "type", - "pattern": "moe.kazv.mxc.test-event" - }], - "actions": ["notify"] - }] - } - } -})"_json)), + pushRules, myUserId, }); REQUIRE( @@ -271,6 +292,48 @@ } ); } + + WHEN("push rules notifies some but is before local read marker, only things after local read marker is added") { + r.localReadMarker = newEvents[3].id(); + auto next = RoomModel::update(r, AddLocalNotificationsAction{ + newEvents, + pushRules, + myUserId, + }); + + REQUIRE( + next.unreadNotificationEventIds + == immer::flex_vector<std::string>{ + newEvents[4].id(), + } + ); + } +} + +TEST_CASE("PostReceiptAction should update local read marker and remove read notifications") +{ + auto myUserId = "@mew:example.com"s; + + auto newEvents = EventList{ + makeEvent(withEventType("moe.kazv.mxc.test-event")), + makeEvent(withEventType("moe.kazv.mxc.test-event")), + makeEvent(withEventType("moe.kazv.mxc.test-event")), + }; + auto r = makeRoom(withRoomTimeline(newEvents)); + r.readReceipts = {{myUserId, ReadReceipt{newEvents[0].id(), 0}}}; + r = RoomModel::update(r, AddLocalNotificationsAction{ + newEvents, + pushRules, + myUserId, + }); + + auto client = makeClient(withRoom(r)); + client.userId = myUserId; + auto receiptPos = newEvents[1].id(); + auto [next, _] = ClientModel::update(client, PostReceiptAction{r.roomId, receiptPos}); + auto nextRoom = next.roomList.rooms.at(r.roomId); + REQUIRE(nextRoom.localReadMarker == receiptPos); + REQUIRE(nextRoom.unreadNotificationEventIds == immer::flex_vector<std::string>{newEvents[2].id()}); } TEST_CASE("RemoveReadLocalNotificationsAction", "[client][room][receipt]")