Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F140515
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
60 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/client/actions/sync.cpp b/src/client/actions/sync.cpp
index b12e290..d1caa75 100644
--- a/src/client/actions/sync.cpp
+++ b/src/client/actions/sync.cpp
@@ -1,336 +1,336 @@
/*
- * Copyright (C) 2020 Tusooa Zhu
+ * Copyright (C) 2021 Tusooa Zhu <tusooa@kazv.moe>
*
* This file is part of libkazv.
*
* libkazv is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* libkazv is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with libkazv. If not, see <https://www.gnu.org/licenses/>.
*/
#include <libkazv-config.hpp>
#include <lager/util.hpp>
#include <zug/transducer/map.hpp>
#include <zug/transducer/cat.hpp>
#include <zug/transducer/filter.hpp>
#include <zug/sequence.hpp>
#include <jobinterface.hpp>
#include <debug.hpp>
#include "cursorutil.hpp"
#include "sync.hpp"
#include "encryption.hpp"
namespace Kazv
{
// Atomicity guaranteed: if the sync action is created
// before an action that reasonably changes Client
// (e.g. roll back to an earlier state, obtain other
// events), but executed
// after that action, the sync will still give continuous
// data about the events. (Sync will not "skip" events)
// This is because this function takes the sync token
// from the ClientModel model it is passed.
ClientResult updateClient(ClientModel m, SyncAction)
{
kzo.client.dbg() << "Start syncing with token " <<
(m.syncToken ? m.syncToken.value() : "<null>") << std::endl;
bool isInitialSync = ! m.syncToken;
m.syncing = true;
std::string filter = m.syncToken ? m.incrementalSyncFilterId : m.initialSyncFilterId;
m.addJob(m.job<SyncJob>()
.make(filter,
m.syncToken,
std::nullopt, // fullState
std::nullopt, // setPresence
// Let initial sync return immediately
isInitialSync ? 0 : m.syncTimeoutMs
)
.withData(json{{"is", isInitialSync ? "initial" : "incremental"}}));
return { m, lager::noop };
}
static KazvEventList loadRoomsFromSyncInPlace(ClientModel &m, SyncJob::Rooms rooms)
{
auto l = std::move(m.roomList);
auto eventsToEmit = KazvEventList{}.transient();
auto updateRoomImpl =
[&l](auto id, auto a) {
l = RoomListModel::update(
std::move(l),
UpdateRoomAction{std::move(id), std::move(a)});
};
auto updateSingleRoom =
[&, updateRoomImpl](const auto &id, const auto &room, auto membership) {
if (!l.has(id) || l[id].membership != membership) {
eventsToEmit.push_back(RoomMembershipChanged{membership, id});
}
updateRoomImpl(id, ChangeMembershipAction{membership});
auto timelineEvents =
intoImmer(
EventList{},
zug::map([=](Event e) {
return Event::fromSync(e, id);
}),
room.timeline.events);
eventsToEmit.append(
intoImmer(
KazvEventList{},
zug::map([=](Event e) -> KazvEvent {
return ReceivingRoomTimelineEvent{std::move(e), id};
}),
timelineEvents).transient());
- updateRoomImpl(id, AppendTimelineAction{timelineEvents});
+ updateRoomImpl(id, AddToTimelineAction{timelineEvents, room.timeline.prevBatch, room.timeline.limited});
if (room.state) {
eventsToEmit.append(
intoImmer(
KazvEventList{},
zug::map([=](Event e) -> KazvEvent {
return ReceivingRoomStateEvent{std::move(e), id};
}),
room.state.value().events).transient());
updateRoomImpl(id, AddStateEventsAction{room.state.value().events});
}
if (room.accountData) {
eventsToEmit.append(
intoImmer(
KazvEventList{},
zug::map([=](Event e) -> KazvEvent {
return ReceivingRoomAccountDataEvent{std::move(e), id};
}),
room.state.value().events).transient());
updateRoomImpl(id, AddAccountDataAction{room.accountData.value().events});
}
};
auto updateJoinedRoom =
[=](const auto &id, const auto &room) {
updateSingleRoom(id, room, RoomMembership::Join);
if (room.ephemeral) {
updateRoomImpl(id, AddEphemeralAction{room.ephemeral.value().events});
}
// TODO update other info such as
// notification and summary
};
auto updateInvitedRoom =
[=](const auto &id, const auto &room) {
updateRoomImpl(id, ChangeMembershipAction{RoomMembership::Invite});
if (room.inviteState) {
updateRoomImpl(id, ChangeInviteStateAction{room.inviteState.value().events});
}
};
auto updateLeftRoom =
[=](const auto &id, const auto &room) {
updateSingleRoom(id, room, RoomMembership::Leave);
};
for (const auto &[id, room]: rooms.join) {
updateJoinedRoom(id, room);
}
// TODO update info for invited rooms
for (const auto &[id, room]: rooms.invite) {
updateInvitedRoom(id, room);
}
for (const auto &[id, room]: rooms.leave) {
updateLeftRoom(id, room);
}
m.roomList = std::move(l);
return eventsToEmit.persistent();
}
static KazvEventList loadPresenceFromSyncInPlace(ClientModel &m, EventList presence)
{
auto eventsToEmit = intoImmer(
KazvEventList{},
zug::map([](Event e) { return ReceivingPresenceEvent{e}; }),
presence);
m.presence = merge(std::move(m.presence), presence, keyOfPresence);
return eventsToEmit;
}
static KazvEventList loadAccountDataFromSyncInPlace(ClientModel &m, EventList accountData)
{
auto eventsToEmit = intoImmer(
KazvEventList{},
zug::map([](Event e) { return ReceivingPresenceEvent{e}; }),
accountData);
m.accountData = merge(std::move(m.accountData), accountData, keyOfAccountData);
return eventsToEmit;
}
static KazvEventList loadToDeviceFromSyncInPlace(ClientModel &m, JsonWrap toDevice)
{
if (toDevice.get().contains("events")) {
auto events = toDevice.get()["events"];
auto msgs = intoImmer(
EventList{},
zug::map([](const json &j) { return Event(j); }),
events);
m.toDevice = std::move(m.toDevice) + msgs;
return intoImmer(
KazvEventList{},
zug::map([](Event e) { return ReceivingToDeviceMessage{e}; }),
msgs);
}
return {};
}
ClientResult processResponse(ClientModel m, SyncResponse r)
{
if (! r.success()) {
m.addTrigger(SyncFailed{});
kzo.client.dbg() << "Sync failed" << std::endl;
kzo.client.dbg() << r.statusCode << std::endl;
if (isBodyJson(r.body)) {
auto j = r.jsonBody();
kzo.client.dbg() << "Json says: " << j.get().dump() << std::endl;
} else {
kzo.client.dbg() << "Response body: "
<< std::get<BaseJob::BytesBody>(r.body) << std::endl;
}
return { std::move(m), lager::noop };
}
kzo.client.dbg() << "Sync successful" << std::endl;
auto rooms = r.rooms();
auto accountData = r.accountData();
auto presence = r.presence();
// load the info that has been sync'd
m.syncToken = r.nextBatch();
if (rooms) {
m.addTriggers(loadRoomsFromSyncInPlace(m, std::move(rooms.value())));
}
if (presence) {
m.addTriggers(loadPresenceFromSyncInPlace(m, std::move(presence.value().events)));
}
if (accountData) {
m.addTriggers(loadAccountDataFromSyncInPlace(m, std::move(accountData.value().events)));
}
m.addTriggers(loadToDeviceFromSyncInPlace(m, r.toDevice()));
auto is = r.dataStr("is");
auto isInitialSync = is == "initial";
if (m.crypto) {
kzo.client.dbg() << "E2EE is on. Processing device lists and one-time key counts." << std::endl;
auto &crypto = m.crypto.value();
// process deviceLists
if (isInitialSync) {
auto encryptedUsers =
zug::sequence(
zug::map([](auto n) { return n.second; })
| zug::filter([](auto room) { return room.encrypted; })
| zug::map([](auto room) { return room.joinedMemberIds(); })
| zug::cat,
// no need to use distinct here as the map will overwrite
m.roomList.rooms);
m.deviceLists.track(std::move(encryptedUsers));
} else {
const auto &l = r.deviceLists().get();
if (l.contains("changed")) {
const auto &changed = l.at("changed");
m.deviceLists.track(changed);
}
if (l.contains("left")) {
const auto &left = l.at("left");
m.deviceLists.untrack(left);
}
}
// deviceOneTimeKeysCount
crypto.setUploadedOneTimeKeysCount(r.deviceOneTimeKeysCount());
auto model = tryDecryptEvents(std::move(m));
m = std::move(model);
}
m.addTrigger(SyncSuccessful{r.nextBatch()});
return { std::move(m), lager::noop };
}
ClientResult updateClient(ClientModel m, PostInitialFiltersAction)
{
if (m.syncing) {
return { std::move(m), lager::noop };
}
Filter initialSyncFilter;
initialSyncFilter.room.timeline.limit = 1;
initialSyncFilter.room.state.lazyLoadMembers = true;
auto firstJob = m.job<DefineFilterJob>()
.make(m.userId, initialSyncFilter)
.withData(json{{"is", "initialSyncFilter"}})
.withQueue("post-filter", CancelFutureIfFailed);
m.addJob(firstJob);
Filter incrementalSyncFilter;
incrementalSyncFilter.room.timeline.limit = 20;
incrementalSyncFilter.room.state.lazyLoadMembers = true;
m.addJob(m.job<DefineFilterJob>()
.make(m.userId, incrementalSyncFilter)
.withData(json{{"is", "incrementalSyncFilter"}})
.withQueue("post-filter", CancelFutureIfFailed));
m.syncing = true;
return { std::move(m), lager::noop };
}
ClientResult processResponse(ClientModel m, DefineFilterResponse r)
{
auto is = r.dataStr("is");
if (! r.success()) {
m.syncing = false;
kzo.client.dbg() << "posting filter failed: " << r.errorCode() << r.errorMessage() << std::endl;
m.addTrigger(PostInitialFiltersFailed{r.errorCode(), r.errorMessage()});
return { std::move(m), lager::noop };
}
kzo.client.dbg() << "filter " << is << " is posted" << std::endl;
if (is == "incrementalSyncFilter") {
m.incrementalSyncFilterId = r.filterId();
m.addTrigger(PostInitialFiltersSuccessful{});
} else {
m.initialSyncFilterId = r.filterId();
}
return { std::move(m), lager::noop };
}
}
diff --git a/src/client/clientutil.hpp b/src/client/clientutil.hpp
index 2eacecf..257d3fb 100644
--- a/src/client/clientutil.hpp
+++ b/src/client/clientutil.hpp
@@ -1,105 +1,131 @@
/*
- * Copyright (C) 2020 Tusooa Zhu
+ * Copyright (C) 2021 Tusooa Zhu <tusooa@kazv.moe>
*
* This file is part of libkazv.
*
* libkazv is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* libkazv is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with libkazv. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <libkazv-config.hpp>
#include <string>
#include <tuple>
#include <immer/map.hpp>
+#include <zug/transducer/filter.hpp>
+#include <zug/transducer/eager.hpp>
#include <lager/deps.hpp>
#include <boost/container_hash/hash.hpp>
+#include <cursorutil.hpp>
#include <jobinterface.hpp>
#include <eventinterface.hpp>
namespace Kazv
{
template<class K, class V, class List, class Func>
immer::map<K, V> merge(immer::map<K, V> map, List list, Func keyOf)
{
for (auto v : list) {
auto key = keyOf(v);
map = std::move(map).set(key, v);
}
return map;
}
inline std::string keyOfPresence(Event e) {
return e.sender();
}
inline std::string keyOfAccountData(Event e) {
return e.type();
}
inline std::string keyOfTimeline(Event e) {
return e.id();
}
inline std::string keyOfEphemeral(Event e) {
return e.type();
}
struct KeyOfState {
std::string type;
std::string stateKey;
};
template<class Archive>
void serialize(Archive &ar, KeyOfState &m)
{
ar(m.type, m.stateKey);
}
inline bool operator==(KeyOfState a, KeyOfState b)
{
return a.type == b.type && a.stateKey == b.stateKey;
}
inline KeyOfState keyOfState(Event e) {
return {e.type(), e.stateKey()};
}
template<class Context>
JobInterface &getJobHandler(Context &&ctx)
{
return lager::get<JobInterface &>(std::forward<Context>(ctx));
}
template<class Context>
EventInterface &getEventEmitter(Context &&ctx)
{
return lager::get<EventInterface &>(std::forward<Context>(ctx));
}
+
+ template<class ImmerT1, class RangeT2, class Pred, class Func>
+ ImmerT1 sortedUniqueMerge(ImmerT1 base, RangeT2 addon, Pred exists, Func keyOf)
+ {
+ auto needToAdd = intoImmer(ImmerT1{},
+ zug::filter([=](auto a) {
+ return !exists(a);
+ }),
+ addon);
+
+ // TODO improve the performance of this
+ auto ret = intoImmer(ImmerT1{},
+ // make sorted according to keyOf
+ zug::eager([=](auto&& range) -> decltype(auto) {
+ std::sort(range.begin(), range.end(),
+ [=](auto a, auto b) {
+ return keyOf(a) < keyOf(b);
+ });
+ return std::forward<decltype(range)>(range);
+ }),
+ std::move(base) + needToAdd);
+ return ret;
+ }
}
namespace std
{
template<> struct hash<Kazv::KeyOfState>
{
std::size_t operator()(const Kazv::KeyOfState & k) const noexcept {
std::size_t seed = 0;
boost::hash_combine(seed, k.type);
boost::hash_combine(seed, k.stateKey);
return seed;
}
};
}
diff --git a/src/client/room/room-model.cpp b/src/client/room/room-model.cpp
index c423113..e4a5881 100644
--- a/src/client/room/room-model.cpp
+++ b/src/client/room/room-model.cpp
@@ -1,176 +1,203 @@
/*
* Copyright (C) 2020 Tusooa Zhu
*
* This file is part of libkazv.
*
* libkazv is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* libkazv is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with libkazv. If not, see <https://www.gnu.org/licenses/>.
*/
#include <libkazv-config.hpp>
#include <lager/util.hpp>
#include <zug/sequence.hpp>
#include <zug/transducer/map.hpp>
#include <zug/transducer/filter.hpp>
#include "debug.hpp"
#include "room-model.hpp"
#include "cursorutil.hpp"
namespace Kazv
{
RoomModel RoomModel::update(RoomModel r, Action a)
{
return lager::match(std::move(a))(
[&](AddStateEventsAction a) {
r.stateEvents = merge(std::move(r.stateEvents), a.stateEvents, keyOfState);
// If m.room.encryption state event appears,
// configure the room to use encryption.
if (r.stateEvents.find(KeyOfState{"m.room.encryption", ""})) {
auto newRoom = update(std::move(r), SetRoomEncryptionAction{});
r = std::move(newRoom);
}
return r;
},
[&](AppendTimelineAction a) {
auto eventIds = intoImmer(immer::flex_vector<std::string>(),
zug::map(keyOfTimeline), a.events);
r.timeline = r.timeline + eventIds;
r.messages = merge(std::move(r.messages), a.events, keyOfTimeline);
return r;
},
[&](PrependTimelineAction a) {
auto eventIds = intoImmer(immer::flex_vector<std::string>(),
zug::map(keyOfTimeline), a.events);
r.timeline = eventIds + r.timeline;
r.messages = merge(std::move(r.messages), a.events, keyOfTimeline);
r.paginateBackToken = a.paginateBackToken;
// if there are no more events we should not allow further paginating
r.canPaginateBack = a.events.size() != 0;
return r;
},
+ [&](AddToTimelineAction a) {
+ auto eventIds = intoImmer(immer::flex_vector<std::string>(),
+ zug::map(keyOfTimeline), a.events);
+ auto oldMessages = r.messages;
+ r.messages = merge(std::move(r.messages), a.events, keyOfTimeline);
+ auto exists =
+ [=](auto eventId) -> bool {
+ return !! oldMessages.find(eventId);
+ };
+ auto key =
+ [=](auto eventId) {
+ return r.messages[eventId].originServerTs();
+ };
+ r.timeline = sortedUniqueMerge(r.timeline, eventIds, exists, key);
+
+ // TODO need other way to determine whether it is limited
+ // in a pagination request (/messages does not have that field)
+ if (a.limited.has_value() && a.limited.value()
+ && a.prevBatch.has_value()) {
+ // this sync is limited, add a Gap here
+ if (!eventIds.empty()) {
+ r.timelineGaps = std::move(r.timelineGaps).set(eventIds[0], a.prevBatch.value());
+ }
+ }
+
+ return r;
+ },
[&](AddAccountDataAction a) {
r.accountData = merge(std::move(r.accountData), a.events, keyOfAccountData);
return r;
},
[&](ChangeMembershipAction a) {
r.membership = a.membership;
return r;
},
[&](ChangeInviteStateAction a) {
r.inviteState = merge(immer::map<KeyOfState, Event>{}, a.events, keyOfState);
return r;
},
[&](AddEphemeralAction a) {
r.ephemeral = merge(std::move(r.ephemeral), a.events, keyOfEphemeral);
return r;
},
[&](SetLocalDraftAction a) {
r.localDraft = a.localDraft;
return r;
},
[&](SetRoomEncryptionAction) {
r.encrypted = true;
return r;
},
[&](MarkMembersFullyLoadedAction) {
r.membersFullyLoaded = true;
return r;
}
);
}
RoomListModel RoomListModel::update(RoomListModel l, Action a)
{
return lager::match(std::move(a))(
[&](UpdateRoomAction a) {
l.rooms = std::move(l.rooms)
.update(a.roomId,
[=](RoomModel oldRoom) {
oldRoom.roomId = a.roomId; // in case it is a new room
return RoomModel::update(std::move(oldRoom), a.roomAction);
});
return l;
}
);
}
immer::flex_vector<std::string> RoomModel::joinedMemberIds() const
{
using MemberNode = std::pair<std::string, Kazv::Event>;
auto memberNameTransducer =
zug::filter(
[](auto val) {
auto [k, v] = val;
auto [type, stateKey] = k;
return type == "m.room.member"s;
})
| zug::map(
[](auto val) {
auto [k, v] = val;
auto [type, stateKey] = k;
return MemberNode{stateKey, v};
})
| zug::filter(
[](auto val) {
auto [stateKey, ev] = val;
return ev.content().get()
.at("membership"s) == "join"s;
})
| zug::map(
[](auto val) {
auto [stateKey, ev] = val;
return stateKey;
});
return intoImmer(
immer::flex_vector<std::string>{},
memberNameTransducer,
stateEvents);
}
static Timestamp defaultRotateMs = 604800000;
static int defaultRotateMsgs = 100;
MegOlmSessionRotateDesc RoomModel::sessionRotateDesc() const
{
auto k = KeyOfState{"m.room.encryption", ""};
auto content = stateEvents[k].content().get();
auto ms = content.contains("rotation_period_ms")
? content["rotation_period_ms"].get<Timestamp>()
: defaultRotateMs;
auto msgs = content.contains("rotation_period_msgs")
? content["rotation_period_msgs"].get<int>()
: defaultRotateMsgs;
return MegOlmSessionRotateDesc{ ms, msgs };
}
bool RoomModel::hasUser(std::string userId) const
{
try {
auto ev = stateEvents.at(KeyOfState{"m.room.member", userId});
if (ev.content().get().at("membership") == "join") {
return true;
}
} catch (const std::exception &) {
return false;
}
return false;
}
}
diff --git a/src/client/room/room-model.hpp b/src/client/room/room-model.hpp
index e1e5dff..9685c4a 100644
--- a/src/client/room/room-model.hpp
+++ b/src/client/room/room-model.hpp
@@ -1,222 +1,235 @@
/*
- * Copyright (C) 2020 Tusooa Zhu
+ * Copyright (C) 2021 Tusooa Zhu <tusooa@kazv.moe>
*
* This file is part of libkazv.
*
* libkazv is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* libkazv is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with libkazv. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <libkazv-config.hpp>
#include <lager/debug/cereal/struct.hpp>
#include <lager/debug/cereal/immer_flex_vector.hpp>
#include <string>
#include <variant>
#include <immer/flex_vector.hpp>
#include <immer/map.hpp>
#include <csapi/sync.hpp>
#include <event.hpp>
#include <crypto.hpp>
#include "data/cereal_map.hpp"
#include "clientutil.hpp"
namespace Kazv
{
struct AddStateEventsAction
{
immer::flex_vector<Event> stateEvents;
};
struct AppendTimelineAction
{
immer::flex_vector<Event> events;
};
struct PrependTimelineAction
{
immer::flex_vector<Event> events;
std::string paginateBackToken;
};
+ struct AddToTimelineAction
+ {
+ /// Events from oldest to latest
+ immer::flex_vector<Event> events;
+ std::optional<std::string> prevBatch;
+ std::optional<bool> limited;
+ };
+
struct AddAccountDataAction
{
immer::flex_vector<Event> events;
};
struct ChangeMembershipAction
{
RoomMembership membership;
};
struct ChangeInviteStateAction
{
immer::flex_vector<Event> events;
};
struct AddEphemeralAction
{
EventList events;
};
struct SetLocalDraftAction
{
std::string localDraft;
};
struct SetRoomEncryptionAction
{
};
struct MarkMembersFullyLoadedAction
{
};
struct RoomModel
{
using Membership = RoomMembership;
std::string roomId;
immer::map<KeyOfState, Event> stateEvents;
immer::map<KeyOfState, Event> inviteState;
+ // Smaller indices mean earlier events
+ // (oldest) 0 --------> n (latest)
immer::flex_vector<std::string> timeline;
immer::map<std::string, Event> messages;
immer::map<std::string, Event> accountData;
Membership membership{};
std::string paginateBackToken;
/// whether this room has earlier events to be fetched
bool canPaginateBack{true};
+ immer::map<std::string /* eventId */, std::string /* prevBatch */> timelineGaps;
+
immer::map<std::string, Event> ephemeral;
std::string localDraft;
bool encrypted{false};
/// a marker to indicate whether we need to rotate
/// the session key earlier than it expires
/// (e.g. when a user in the room's device list changed
/// or when someone joins or leaves)
bool shouldRotateSessionKey{true};
bool membersFullyLoaded{false};
immer::flex_vector<std::string> joinedMemberIds() const;
MegOlmSessionRotateDesc sessionRotateDesc() const;
bool hasUser(std::string userId) const;
using Action = std::variant<
AddStateEventsAction,
AppendTimelineAction,
PrependTimelineAction,
+ AddToTimelineAction,
AddAccountDataAction,
ChangeMembershipAction,
ChangeInviteStateAction,
AddEphemeralAction,
SetLocalDraftAction,
SetRoomEncryptionAction,
MarkMembersFullyLoadedAction
>;
static RoomModel update(RoomModel r, Action a);
};
using RoomAction = RoomModel::Action;
inline bool operator==(RoomModel a, RoomModel b)
{
return a.roomId == b.roomId
&& a.stateEvents == b.stateEvents
&& a.inviteState == b.inviteState
&& a.timeline == b.timeline
&& a.messages == b.messages
&& a.accountData == b.accountData
&& a.membership == b.membership
&& a.paginateBackToken == b.paginateBackToken
&& a.canPaginateBack == b.canPaginateBack
&& a.ephemeral == b.ephemeral
&& a.localDraft == b.localDraft
&& a.encrypted == b.encrypted
&& a.shouldRotateSessionKey == b.shouldRotateSessionKey
&& a.membersFullyLoaded == b.membersFullyLoaded;
}
struct UpdateRoomAction
{
std::string roomId;
RoomAction roomAction;
};
struct RoomListModel
{
immer::map<std::string, RoomModel> rooms;
inline auto at(std::string id) const { return rooms.at(id); }
inline auto operator[](std::string id) const { return rooms[id]; }
inline bool has(std::string id) const { return rooms.find(id); }
using Action = std::variant<
UpdateRoomAction
>;
static RoomListModel update(RoomListModel l, Action a);
};
using RoomListAction = RoomListModel::Action;
inline bool operator==(RoomListModel a, RoomListModel b)
{
return a.rooms == b.rooms;
}
#ifndef NDEBUG
LAGER_CEREAL_STRUCT(AddStateEventsAction);
LAGER_CEREAL_STRUCT(AppendTimelineAction);
LAGER_CEREAL_STRUCT(PrependTimelineAction);
LAGER_CEREAL_STRUCT(AddAccountDataAction);
LAGER_CEREAL_STRUCT(ChangeMembershipAction);
LAGER_CEREAL_STRUCT(SetLocalDraftAction);
LAGER_CEREAL_STRUCT(ChangeInviteStateAction);
LAGER_CEREAL_STRUCT(UpdateRoomAction);
#endif
template<class Archive>
void serialize(Archive &ar, RoomModel &r, std::uint32_t const /*version*/)
{
ar(r.roomId,
r.stateEvents,
r.inviteState,
r.timeline,
r.messages,
r.accountData,
r.membership,
r.paginateBackToken,
r.canPaginateBack);
}
template<class Archive>
void serialize(Archive &ar, RoomListModel &l, std::uint32_t const /*version*/)
{
ar(l.rooms);
}
}
CEREAL_CLASS_VERSION(Kazv::RoomModel, 0);
CEREAL_CLASS_VERSION(Kazv::RoomListModel, 0);
diff --git a/src/client/room/room.cpp b/src/client/room/room.cpp
index c9144be..e71738c 100644
--- a/src/client/room/room.cpp
+++ b/src/client/room/room.cpp
@@ -1,207 +1,213 @@
/*
* Copyright (C) 2021 Tusooa Zhu <tusooa@vista.aero>
*
* This file is part of libkazv.
*
* libkazv is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* libkazv is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with libkazv. If not, see <https://www.gnu.org/licenses/>.
*/
#include <libkazv-config.hpp>
#include <debug.hpp>
#include "room.hpp"
namespace Kazv
{
Room::Room(lager::reader<SdkModel> sdk,
lager::reader<std::string> roomId,
Context<ClientAction> ctx)
: m_sdk(sdk)
, m_room(lager::with(m_sdk.map(&SdkModel::c)[&ClientModel::roomList], roomId)
.map([](auto rooms, auto id) {
return rooms[id];
}).make())
, m_ctx(ctx)
{
}
auto Room::setLocalDraft(std::string localDraft) const
-> PromiseT
{
using namespace CursorOp;
return m_ctx.dispatch(UpdateRoomAction{+roomId(), SetLocalDraftAction{localDraft}});
}
auto Room::sendMessage(Event msg) const
-> PromiseT
{
using namespace CursorOp;
auto hasCrypto = ~m_sdk.map([](const auto &sdk) -> bool {
return sdk.c().crypto.has_value();
});
auto roomEncrypted = ~m_room[&RoomModel::encrypted];
auto noFullMembers = ~m_room[&RoomModel::membersFullyLoaded]
.map([](auto b) { return !b; });
auto rid = +roomId();
// Don't use m_ctx directly in the callbacks
// as `this` may have been destroyed when
// the callbacks are called.
auto ctx = m_ctx;
auto promise = ctx.createResolvedPromise(true);
if (+allCursors(hasCrypto, roomEncrypted, noFullMembers)) {
kzo.client.dbg() << "The members of " << rid
<< " are not fully loaded." << std::endl;
promise = promise
.then([=](auto) {
return ctx.dispatch(GetRoomStatesAction{rid});
})
.then([=](auto succ) {
if (! succ) {
kzo.client.warn() << "Loading members of " << rid
<< " failed." << std::endl;
return ctx.createResolvedPromise(false);
} else {
// XXX remove the hard-coded initialSync parameter
return ctx.dispatch(QueryKeysAction{true})
.then([](auto succ) {
if (! succ) {
kzo.client.warn() << "Query keys failed" << std::endl;
}
return succ;
});
}
});
}
return promise
.then([=](auto succ) {
if (! succ) {
return ctx.createResolvedPromise(false);
}
return ctx.dispatch(SendMessageAction{rid, msg});
});
}
auto Room::sendTextMessage(std::string text) const
-> PromiseT
{
json j{
{"type", "m.room.message"},
{"content", {
{"msgtype", "m.text"},
{"body", text}
}
}
};
Event e{j};
return sendMessage(e);
}
auto Room::refreshRoomState() const
-> PromiseT
{
using namespace CursorOp;
return m_ctx.dispatch(GetRoomStatesAction{+roomId()});
}
auto Room::getStateEvent(std::string type, std::string stateKey) const
-> PromiseT
{
using namespace CursorOp;
return m_ctx.dispatch(GetStateEventAction{+roomId(), type, stateKey});
}
auto Room::sendStateEvent(Event state) const
-> PromiseT
{
using namespace CursorOp;
return m_ctx.dispatch(SendStateEventAction{+roomId(), state});
}
auto Room::setName(std::string name) const
-> PromiseT
{
json j{
{"type", "m.room.name"},
{"content", {
{"name", name}
}
}
};
Event e{j};
return sendStateEvent(e);
}
auto Room::setTopic(std::string topic) const
-> PromiseT
{
json j{
{"type", "m.room.topic"},
{"content", {
{"topic", topic}
}
}
};
Event e{j};
return sendStateEvent(e);
}
auto Room::invite(std::string userId) const
-> PromiseT
{
using namespace CursorOp;
return m_ctx.dispatch(InviteToRoomAction{+roomId(), userId});
}
auto Room::setTyping(bool typing, std::optional<int> timeoutMs) const
-> PromiseT
{
using namespace CursorOp;
return m_ctx.dispatch(SetTypingAction{+roomId(), typing, timeoutMs});
}
auto Room::leave() const
-> PromiseT
{
using namespace CursorOp;
return m_ctx.dispatch(LeaveRoomAction{+roomId()});
}
auto Room::forget() const
-> PromiseT
{
using namespace CursorOp;
return m_ctx.dispatch(ForgetRoomAction{+roomId()});
}
auto Room::setPinnedEvents(immer::flex_vector<std::string> eventIds) const
-> PromiseT
{
json j{
{"type", "m.room.pinned_events"},
{"content", {
{"pinned", eventIds}
}
}
};
Event e{j};
return sendStateEvent(e);
}
+ auto Room::timelineGaps() const
+ -> lager::reader<immer::map<std::string /* eventId */,
+ std::string /* prevBatch */>>
+ {
+ return m_room[&RoomModel::timelineGaps];
+ }
}
diff --git a/src/client/room/room.hpp b/src/client/room/room.hpp
index 5bd1032..33e2627 100644
--- a/src/client/room/room.hpp
+++ b/src/client/room/room.hpp
@@ -1,383 +1,385 @@
/*
* Copyright (C) 2020 Tusooa Zhu
*
* This file is part of libkazv.
*
* libkazv is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* libkazv is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with libkazv. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <libkazv-config.hpp>
#include <lager/reader.hpp>
#include <lager/context.hpp>
#include <lager/with.hpp>
#include <lager/constant.hpp>
#include <lager/lenses/optional.hpp>
#include <zug/transducer/map.hpp>
#include <zug/transducer/filter.hpp>
#include <zug/sequence.hpp>
#include <immer/flex_vector_transient.hpp>
#include "debug.hpp"
#include "sdk-model.hpp"
#include "client-model.hpp"
#include "room-model.hpp"
#include <cursorutil.hpp>
namespace Kazv
{
/**
* Represent a Matrix room.
*/
class Room
{
public:
using PromiseT = SingleTypePromise<DefaultRetType>;
/**
* Constructor.
*
* Construct the room with @c roomId .
*
*/
Room(lager::reader<SdkModel> sdk,
lager::reader<std::string> roomId,
Context<ClientAction> ctx);
/* lager::reader<MapT<KeyOfState, Event>> */
inline auto stateEvents() const {
return m_room
[&RoomModel::stateEvents];
}
/* lager::reader<std::optional<Event>> */
inline auto stateOpt(KeyOfState k) const {
return stateEvents()
[std::move(k)];
}
/* lager::reader<Event> */
inline auto state(KeyOfState k) const {
return stateOpt(k)
[lager::lenses::or_default];
}
/* lager::reader<RangeT<Event>> */
inline auto timelineEvents() const {
return m_room
.xform(zug::map([](auto r) {
auto messages = r.messages;
auto timeline = r.timeline;
return intoImmer(
immer::flex_vector<Event>{},
zug::map([=](auto eventId) {
return messages[eventId];
}),
timeline);
}));
}
/* lager::reader<std::string> */
inline auto name() const {
using namespace lager::lenses;
return stateEvents()
[KeyOfState{"m.room.name", ""}]
[or_default]
.xform(zug::map([](Event ev) {
auto content = ev.content().get();
return
content.contains("name")
? std::string(content["name"])
// TODO: use heroes to generate a name
: "<no name>";
}));
}
/* lager::reader<std::string> */
inline auto avatarMxcUri() const {
using namespace lager::lenses;
return stateEvents()
[KeyOfState{"m.room.avatar", ""}]
[or_default]
.xform(zug::map([](Event ev) {
auto content = ev.content().get();
return
content.contains("avatar")
? std::string(content["avatar"])
: "";
}));
}
/* lager::reader<RangeT<std::string>> */
inline auto members() const {
return m_room.xform(zug::map([=](auto room) {
return room.joinedMemberIds();
}));
}
inline auto memberEventByCursor(lager::reader<std::string> userId) const {
return lager::with(m_room[&RoomModel::stateEvents], userId)
.xform(zug::map([](auto events, auto userId) {
auto k = KeyOfState{"m.room.member", userId};
return events[k];
}));
}
/* lager::reader<std::optional<Event>> */
inline auto memberEventFor(std::string userId) const {
return memberEventByCursor(lager::make_constant(userId));
}
/**
* Get whether this room is encrypted.
*
* The encryption status is changed to true if the client
* receives a state event that turns on encryption.
* If that state event is removed later, the status will
* not be changed.
*
* @return A lager::reader<bool> that contains
* whether this room is encrypted.
*/
lager::reader<bool> encrypted() const;
/*lager::reader<std::string>*/
KAZV_WRAP_ATTR(RoomModel, m_room, roomId);
/*lager::reader<RoomMembership>*/
KAZV_WRAP_ATTR(RoomModel, m_room, membership);
/*lager::reader<std::string>*/
KAZV_WRAP_ATTR(RoomModel, m_room, localDraft);
/* lager::reader<bool> */
KAZV_WRAP_ATTR(RoomModel, m_room, membersFullyLoaded);
/**
* Set local draft for this room.
*
* After the returned Promise is resolved,
* @c localDraft() will contain @c localDraft .
*
* @param localDraft The local draft to send.
* @return A Promise that resolves when the local draft
* has been set, or when there is an error.
*/
PromiseT setLocalDraft(std::string localDraft) const;
/**
* Send an event to this room.
*
* @param msg The message to send
* @return A Promise that resolves when the event has been sent,
* or when there is an error.
*/
PromiseT sendMessage(Event msg) const;
/**
* Send a text message to this room.
*
* @param text The text
* @return A Promise that resolves when the text message has
* been sent, or when there is an error.
*/
PromiseT sendTextMessage(std::string text) const;
/**
* Get the full state of this room.
*
* This method will update the Client as needed.
*
* After the returned Promise resolves successfully,
* @c stateEvents() will contain the fetched state.
*
* @return A Promise that resolves when the room state
* has been fetched, or when there is an error.
*/
PromiseT refreshRoomState() const;
/**
* Get one state event with @c type and @c stateKey .
*
* This method will update the Client as needed.
*
* After the returned Promise resolves successfully,
* @c state({type,stateKey}) will contain the fetched
* state event.
*
* @return A Promise that resolves when the state
* event has been fetched, or when there is an error.
*/
PromiseT getStateEvent(std::string type, std::string stateKey) const;
/**
* Send a state event to this room.
*
* @param state The state event to send.
* @return A Promise that resolves when the state event
* has been sent, or when there is an error.
*/
PromiseT sendStateEvent(Event state) const;
/**
* Set the room name.
*
* @param name The new name for this room.
* @return A Promise that resolves when the state event
* for the name change has been sent, or when there is an error.
*/
PromiseT setName(std::string name) const;
// lager::reader<std::string>
inline auto topic() const {
using namespace lager::lenses;
return stateEvents()
[KeyOfState{"m.room.topic", ""}]
[or_default]
.xform(eventContent
| jsonAtOr("topic"s, ""s));
}
/**
* Set the room topic.
*
* @param topic The new topic for this room.
* @return A Promise that resolves when the state event
* for the topic change has been sent, or when there is an error.
*/
PromiseT setTopic(std::string topic) const;
/**
* Invite a user to this room
*
* @param userId The user id for the user to invite.
* @return A Promise that resolves when the state event
* for the invite has been sent, or when there is an error.
*/
PromiseT invite(std::string userId) const;
/* lager::reader<MapT<std::string, Event>> */
inline auto ephemeralEvents() const {
return m_room
[&RoomModel::ephemeral];
}
/* lager::reader<std::optional<Event>> */
inline auto ephemeralOpt(std::string type) const {
return m_room
[&RoomModel::ephemeral]
[type];
}
/* lager::reader<Event> */
inline auto ephemeral(std::string type) const {
return m_room
[&RoomModel::ephemeral]
[type]
[lager::lenses::or_default];
}
/* lager::reader<RangeT<std::string>> */
inline auto typingUsers() const {
using namespace lager::lenses;
return ephemeral("m.typing")
.xform(eventContent
| jsonAtOr("user_ids",
immer::flex_vector<std::string>{}));
}
/**
* Set the typing status of the current user in this room.
*
* @param typing Whether the user is now typing.
* @param timeoutMs How long this typing status should last,
* in milliseconds.
* @return A Promise that resolves when the typing status
* has been sent, or when there is an error.
*/
PromiseT setTyping(bool typing, std::optional<int> timeoutMs) const;
/* lager::reader<MapT<std::string, Event>> */
inline auto accountDataEvents() const {
return m_room
[&RoomModel::accountData];
}
/* lager::reader<std::optional<Event>> */
inline auto accountDataOpt(std::string type) const {
return m_room
[&RoomModel::accountData]
[type];
}
/* lager::reader<Event> */
inline auto accountData(std::string type) const {
return m_room
[&RoomModel::accountData]
[type]
[lager::lenses::or_default];
}
/* lager::reader<std::string> */
inline auto readMarker() const {
using namespace lager::lenses;
return accountData("m.fully_read")
.xform(eventContent
| jsonAtOr("event_id", std::string{}));
}
/**
* Leave this room.
*
* @return A Promise that resolves when the state event
* for the leaving has been sent, or when there is an error.
*/
PromiseT leave() const;
/**
* Forget this room.
*
* One can only forget a room when they have already left it.
*
* @return A Promise that resolves when the room has been
* forgot, or when there is an error.
*/
PromiseT forget() const;
/* lager::reader<JsonWrap> */
inline auto avatar() const {
return state(KeyOfState{"m.room.avatar", ""})
.xform(eventContent);
}
/* lager::reader<RangeT<std::string>> */
inline auto pinnedEvents() const {
return state(KeyOfState{"m.room.pinned_events", ""})
.xform(eventContent
| jsonAtOr("pinned", immer::flex_vector<std::string>{}));
}
/**
* Set pinned events of this room
*
* @param eventIds The event ids of the new pinned events
*
* @return A Promise that resolves when the state event
* for the pinned events change has been sent, or when there is an error.
*/
PromiseT setPinnedEvents(immer::flex_vector<std::string> eventIds) const;
+
+ lager::reader<immer::map<std::string /* eventId */, std::string /* prevBatch */>> timelineGaps() const;
private:
lager::reader<SdkModel> m_sdk;
lager::reader<RoomModel> m_room;
Context<ClientAction> m_ctx;
};
}
diff --git a/src/tests/client/sync-test.cpp b/src/tests/client/sync-test.cpp
index 13fcb1c..89c63f3 100644
--- a/src/tests/client/sync-test.cpp
+++ b/src/tests/client/sync-test.cpp
@@ -1,296 +1,300 @@
/*
* Copyright (C) 2020 Tusooa Zhu <tusooa@vista.aero>
*
* This file is part of libkazv.
*
* libkazv is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* libkazv is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with libkazv. If not, see <https://www.gnu.org/licenses/>.
*/
#include <libkazv-config.hpp>
#include <catch2/catch.hpp>
#include <boost/asio.hpp>
#include <zug/into_vector.hpp>
#include <asio-promise-handler.hpp>
#include <cursorutil.hpp>
#include <sdk-model.hpp>
#include <client/client.hpp>
#include "client-test-util.hpp"
// The example response is adapted from https://matrix.org/docs/spec/client_server/latest
static json syncResponseJson = R"({
"next_batch": "s72595_4483_1934",
"presence": {
"events": [
{
"content": {
"avatar_url": "mxc://localhost:wefuiwegh8742w",
"last_active_ago": 2478593,
"presence": "online",
"currently_active": false,
"status_msg": "Making cupcakes"
},
"type": "m.presence",
"sender": "@example:localhost"
}
]
},
"account_data": {
"events": [
{
"type": "org.example.custom.config",
"content": {
"custom_config_key": "custom_config_value"
}
}
]
},
"rooms": {
"join": {
"!726s6s6q:example.com": {
"summary": {
"m.heroes": [
"@alice:example.com",
"@bob:example.com"
],
"m.joined_member_count": 2,
"m.invited_member_count": 0
},
"state": {
"events": [
{
"content": {
"membership": "join",
"avatar_url": "mxc://example.org/SEsfnsuifSDFSSEF",
"displayname": "Alice Margatroid"
},
"type": "m.room.member",
"event_id": "$143273582443PhrSn:example.org",
"room_id": "!726s6s6q:example.com",
"sender": "@example:example.org",
"origin_server_ts": 1432735824653,
"unsigned": {
"age": 1234
},
"state_key": "@alice:example.org"
}
]
},
"timeline": {
"events": [
{
"content": {
"membership": "join",
"avatar_url": "mxc://example.org/SEsfnsuifSDFSSEF",
"displayname": "Alice Margatroid"
},
"type": "m.room.member",
"event_id": "$143273582443PhrSn:example.org",
"room_id": "!726s6s6q:example.com",
"sender": "@example:example.org",
"origin_server_ts": 1432735824653,
"unsigned": {
"age": 1234
},
"state_key": "@alice:example.org"
},
{
"content": {
"body": "This is an example text message",
"msgtype": "m.text",
"format": "org.matrix.custom.html",
"formatted_body": "<b>This is an example text message</b>"
},
"type": "m.room.message",
"event_id": "$anothermessageevent:example.org",
"room_id": "!726s6s6q:example.com",
"sender": "@example:example.org",
"origin_server_ts": 1432735824653,
"unsigned": {
"age": 1234
}
}
],
"limited": true,
"prev_batch": "t34-23535_0_0"
},
"ephemeral": {
"events": [
{
"content": {
"user_ids": [
"@alice:matrix.org",
"@bob:example.com"
]
},
"type": "m.typing",
"room_id": "!jEsUZKDJdhlrceRyVU:example.org"
}
]
},
"account_data": {
"events": [
{
"content": {
"tags": {
"u.work": {
"order": 0.9
}
}
},
"type": "m.tag"
},
{
"type": "org.example.custom.room.config",
"content": {
"custom_config_key": "custom_config_value"
}
},
{
"type": "m.fully_read",
"content": {
"event_id": "$anothermessageevent:example.org"
}
}
]
}
}
},
"invite": {
"!696r7674:example.com": {
"invite_state": {
"events": [
{
"sender": "@alice:example.com",
"type": "m.room.name",
"state_key": "",
"content": {
"name": "My Room Name"
}
},
{
"sender": "@alice:example.com",
"type": "m.room.member",
"state_key": "@bob:example.com",
"content": {
"membership": "invite"
}
}
]
}
}
},
"leave": {}
},
"to_device": {
"events": [
{
"sender": "@alice:example.com",
"type": "m.new_device",
"content": {
"device_id": "XYZABCDE",
"rooms": ["!726s6s6q:example.com"]
}
}
]
}
})"_json;
TEST_CASE("use sync response to update client model", "[client][sync]")
{
using namespace Kazv::CursorOp;
boost::asio::io_context io;
AsioPromiseHandler ph{io.get_executor()};
auto store = createTestClientStore(ph);
auto resp = createResponse("Sync", syncResponseJson, json{{"is", "initial"}});
auto client = Client(store.reader().map([](auto c) { return SdkModel{c}; }), store,
std::nullopt);
store.dispatch(ProcessResponseAction{resp});
io.run();
auto rooms = +client.rooms();
std::string roomId = "!726s6s6q:example.com";
SECTION("rooms should be added") {
REQUIRE(rooms.find(roomId));
}
auto r = client.room(roomId);
SECTION("room members should be updated") {
auto members = +r.members();
auto hasAlice = zug::into_vector(
zug::filter([](auto id) { return id == "@alice:example.org"; }),
members)
.size() > 0;
REQUIRE(hasAlice);
}
SECTION("ephemeral events should be updated") {
auto users = +r.typingUsers();
REQUIRE((users == immer::flex_vector<std::string>{
"@alice:matrix.org",
"@bob:example.com"
}));
}
auto eventId = "$anothermessageevent:example.org"s;
SECTION("timeline should be updated") {
auto timeline = +r.timelineEvents();
auto filtered = zug::into_vector(
zug::filter([=](auto event) { return event.id() == eventId; }),
timeline);
auto hasEvent = filtered.size() > 0;
REQUIRE(hasEvent);
auto onlyOneEvent = filtered.size() == 1;
REQUIRE(onlyOneEvent);
auto ev = filtered[0];
auto eventHasRoomId = ev.originalJson().get().contains("room_id"s);
REQUIRE(eventHasRoomId);
+
+ auto gaps = +r.timelineGaps();
+ // first event in the batch, correspond to its prevBatch
+ REQUIRE(gaps.at("$143273582443PhrSn:example.org") == "t34-23535_0_0");
}
SECTION("fully read marker should be updated") {
auto readMarker = +r.readMarker();
REQUIRE(readMarker == eventId);
}
SECTION("toDevice should be updated") {
auto toDevice = +client.toDevice();
REQUIRE(toDevice.size() == 1);
REQUIRE(toDevice[0].sender() == "@alice:example.com");
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Jan 19, 11:36 PM (1 d, 21 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
55510
Default Alt Text
(60 KB)
Attached To
Mode
rL libkazv
Attached
Detach File
Event Timeline
Log In to Comment