Line data Source code
1 : /*
2 : * Copyright (C) 2004-2026 Savoir-faire Linux Inc.
3 : *
4 : * This program is free software: you can redistribute it and/or modify
5 : * it under the terms of the GNU General Public License as published by
6 : * the Free Software Foundation, either version 3 of the License, or
7 : * (at your option) any later version.
8 : *
9 : * This program is distributed in the hope that it will be useful,
10 : * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 : * GNU General Public License for more details.
13 : *
14 : * You should have received a copy of the GNU General Public License
15 : * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 : */
17 :
18 : #include "conversation.h"
19 :
20 : #include "account_const.h"
21 : #include "jamiaccount.h"
22 : #include "jamidht/collaborative_editing.h"
23 : #include "client/jami_signal.h"
24 : #include "swarm/swarm_manager.h"
25 : #include "conversationrepository.h"
26 : #include "timestamp.h"
27 :
28 : #ifdef ENABLE_PLUGIN
29 : #include "manager.h"
30 : #include "plugin/jamipluginmanager.h"
31 : #include "plugin/streamdata.h"
32 : #endif
33 : #include "jami/conversation_interface.h"
34 : #include "jami/configurationmanager_interface.h"
35 :
36 : #include "fileutils.h"
37 : #include "json_utils.h"
38 : #include "logger.h"
39 : #include "presence_manager.h"
40 : #include "string_utils.h"
41 :
42 : #include <opendht/thread_pool.h>
43 : #include <opendht/infohash.h>
44 : #include <fmt/compile.h>
45 : #include <asio/error_code.hpp>
46 :
47 : #include <algorithm>
48 : #include <memory>
49 : #include <charconv>
50 : #include <set>
51 : #include <string_view>
52 : #include <tuple>
53 : #include <optional>
54 : #include <utility>
55 : #include <deque>
56 : #include <chrono>
57 : #include <ctime>
58 : #include <functional>
59 : #include <atomic>
60 : #include <ranges>
61 : #include <regex>
62 :
63 : namespace jami {
64 :
65 : static const char* const LAST_MODIFIED = "lastModified";
66 :
67 : namespace {
68 :
69 : // Read a timestamp from JSON, preferring the millisecond key and falling
70 : // back to the legacy seconds key (written by older devices).
71 : TimePoint
72 689 : timePointFromJson(const Json::Value& json, const char* msKey, const char* secondsKey)
73 : {
74 689 : if (json.isMember(msKey))
75 334 : return timePointFromMilliseconds(json[msKey].asLargestInt());
76 355 : return timePointFromSeconds(json[secondsKey].asLargestInt());
77 : }
78 :
79 : // Resolve a timestamp from msgpack values, preferring milliseconds.
80 : TimePoint
81 947 : resolveTimePoint(const std::optional<int64_t>& ms, int64_t seconds)
82 : {
83 947 : return ms ? timePointFromMilliseconds(*ms) : timePointFromSeconds(seconds);
84 : }
85 :
86 : } // namespace
87 :
88 17 : ConvInfo::ConvInfo(const Json::Value& json)
89 : {
90 17 : id = json[ConversationMapKeys::ID].asString();
91 17 : created = timePointFromJson(json, ConversationMapKeys::CREATED_MS, ConversationMapKeys::CREATED);
92 17 : removed = timePointFromJson(json, ConversationMapKeys::REMOVED_MS, ConversationMapKeys::REMOVED);
93 17 : erased = timePointFromJson(json, ConversationMapKeys::ERASED_MS, ConversationMapKeys::ERASED);
94 42 : for (const auto& v : json[ConversationMapKeys::MEMBERS]) {
95 25 : members.emplace(v["uri"].asString());
96 : }
97 17 : lastDisplayed = json[ConversationMapKeys::LAST_DISPLAYED].asString();
98 17 : if (json.isMember(ConversationMapKeys::MODE))
99 15 : mode = static_cast<ConversationMode>(json[ConversationMapKeys::MODE].asInt());
100 17 : if (json.isMember(ConversationMapKeys::INVITED)) {
101 3 : const auto& invitedJson = json[ConversationMapKeys::INVITED];
102 7 : for (const auto& uri : invitedJson.getMemberNames())
103 7 : invited[uri] = timePointFromMilliseconds(invitedJson[uri].asLargestInt());
104 : }
105 17 : }
106 :
107 : Json::Value
108 31 : ConvInfo::toJson() const
109 : {
110 31 : Json::Value json;
111 31 : json[ConversationMapKeys::ID] = id;
112 31 : json[ConversationMapKeys::CREATED] = Json::Int64(toSecondsSinceEpoch(created));
113 31 : json[ConversationMapKeys::CREATED_MS] = Json::Int64(toMillisecondsSinceEpoch(created));
114 31 : if (removed != TimePoint {}) {
115 2 : json[ConversationMapKeys::REMOVED] = Json::Int64(toSecondsSinceEpoch(removed));
116 2 : json[ConversationMapKeys::REMOVED_MS] = Json::Int64(toMillisecondsSinceEpoch(removed));
117 : }
118 31 : if (erased != TimePoint {}) {
119 1 : json[ConversationMapKeys::ERASED] = Json::Int64(toSecondsSinceEpoch(erased));
120 1 : json[ConversationMapKeys::ERASED_MS] = Json::Int64(toMillisecondsSinceEpoch(erased));
121 : }
122 81 : for (const auto& m : members) {
123 50 : Json::Value member;
124 50 : member["uri"] = m;
125 50 : json[ConversationMapKeys::MEMBERS].append(member);
126 50 : }
127 31 : json[ConversationMapKeys::LAST_DISPLAYED] = lastDisplayed;
128 31 : json[ConversationMapKeys::MODE] = static_cast<int>(mode);
129 31 : if (!invited.empty()) {
130 7 : Json::Value invitedJson;
131 16 : for (const auto& [uri, t] : invited)
132 9 : invitedJson[uri] = Json::Int64(toMillisecondsSinceEpoch(t));
133 7 : json[ConversationMapKeys::INVITED] = std::move(invitedJson);
134 7 : }
135 31 : return json;
136 0 : }
137 :
138 : void
139 297 : ConvInfo::msgpack_unpack(const msgpack::object& o)
140 : {
141 297 : if (o.type != msgpack::type::MAP)
142 0 : throw msgpack::type_error();
143 297 : int64_t createdSec = 0, removedSec = 0, erasedSec = 0;
144 297 : std::optional<int64_t> createdMs, removedMs, erasedMs;
145 297 : std::map<std::string, int64_t> invitedMs;
146 3560 : for (uint32_t i = 0; i < o.via.map.size; ++i) {
147 3263 : const auto& kv = o.via.map.ptr[i];
148 3263 : if (kv.key.type != msgpack::type::STR)
149 0 : continue;
150 3263 : std::string_view key(kv.key.via.str.ptr, kv.key.via.str.size);
151 3262 : if (key == ConversationMapKeys::ID)
152 297 : kv.val.convert(id);
153 2965 : else if (key == ConversationMapKeys::CREATED)
154 297 : kv.val.convert(createdSec);
155 2669 : else if (key == ConversationMapKeys::REMOVED)
156 297 : kv.val.convert(removedSec);
157 2372 : else if (key == ConversationMapKeys::ERASED)
158 297 : kv.val.convert(erasedSec);
159 2075 : else if (key == ConversationMapKeys::CREATED_MS)
160 296 : createdMs = kv.val.as<int64_t>();
161 1778 : else if (key == ConversationMapKeys::REMOVED_MS)
162 296 : removedMs = kv.val.as<int64_t>();
163 1482 : else if (key == ConversationMapKeys::ERASED_MS)
164 296 : erasedMs = kv.val.as<int64_t>();
165 1187 : else if (key == ConversationMapKeys::MEMBERS)
166 297 : kv.val.convert(members);
167 890 : else if (key == ConversationMapKeys::LAST_DISPLAYED)
168 297 : kv.val.convert(lastDisplayed);
169 593 : else if (key == ConversationMapKeys::MODE)
170 297 : kv.val.convert(mode);
171 296 : else if (key == ConversationMapKeys::INVITED)
172 296 : kv.val.convert(invitedMs);
173 : }
174 297 : created = resolveTimePoint(createdMs, createdSec);
175 297 : removed = resolveTimePoint(removedMs, removedSec);
176 297 : erased = resolveTimePoint(erasedMs, erasedSec);
177 297 : invited.clear();
178 335 : for (const auto& [uri, ms] : invitedMs)
179 38 : invited[uri] = timePointFromMilliseconds(ms);
180 297 : }
181 :
182 : void
183 0 : ConvInfo::msgpack_object(msgpack::object* o, msgpack::zone& z) const
184 : {
185 0 : int64_t createdSec = toSecondsSinceEpoch(created);
186 0 : int64_t removedSec = toSecondsSinceEpoch(removed);
187 0 : int64_t erasedSec = toSecondsSinceEpoch(erased);
188 0 : int64_t createdMs = toMillisecondsSinceEpoch(created);
189 0 : int64_t removedMs = toMillisecondsSinceEpoch(removed);
190 0 : int64_t erasedMs = toMillisecondsSinceEpoch(erased);
191 0 : std::map<std::string, int64_t> invitedMs;
192 0 : for (const auto& [uri, t] : invited)
193 0 : invitedMs[uri] = toMillisecondsSinceEpoch(t);
194 0 : msgpack::type::make_define_map(ConversationMapKeys::ID,
195 0 : id,
196 : ConversationMapKeys::CREATED,
197 : createdSec,
198 : ConversationMapKeys::REMOVED,
199 : removedSec,
200 : ConversationMapKeys::ERASED,
201 : erasedSec,
202 : ConversationMapKeys::MEMBERS,
203 0 : members,
204 : ConversationMapKeys::LAST_DISPLAYED,
205 0 : lastDisplayed,
206 : ConversationMapKeys::MODE,
207 0 : mode,
208 : ConversationMapKeys::CREATED_MS,
209 : createdMs,
210 : ConversationMapKeys::REMOVED_MS,
211 : removedMs,
212 : ConversationMapKeys::ERASED_MS,
213 : erasedMs,
214 : ConversationMapKeys::INVITED,
215 : invitedMs)
216 0 : .msgpack_object(o, z);
217 0 : }
218 :
219 : // ConversationRequest
220 319 : ConversationRequest::ConversationRequest(const Json::Value& json)
221 : {
222 319 : received = timePointFromJson(json, ConversationMapKeys::RECEIVED_MS, ConversationMapKeys::RECEIVED);
223 319 : declined = timePointFromJson(json, ConversationMapKeys::DECLINED_MS, ConversationMapKeys::DECLINED);
224 319 : from = json[ConversationMapKeys::FROM].asString();
225 319 : conversationId = json[ConversationMapKeys::CONVERSATIONID].asString();
226 319 : auto& md = json[ConversationMapKeys::METADATAS];
227 641 : for (const auto& member : md.getMemberNames()) {
228 322 : metadatas.emplace(member, md[member].asString());
229 319 : }
230 319 : }
231 :
232 : Json::Value
233 8 : ConversationRequest::toJson() const
234 : {
235 8 : Json::Value json;
236 8 : json[ConversationMapKeys::CONVERSATIONID] = conversationId;
237 8 : json[ConversationMapKeys::FROM] = from;
238 8 : json[ConversationMapKeys::RECEIVED] = Json::Int64(toSecondsSinceEpoch(received));
239 8 : json[ConversationMapKeys::RECEIVED_MS] = Json::Int64(toMillisecondsSinceEpoch(received));
240 8 : if (declined != TimePoint {}) {
241 0 : json[ConversationMapKeys::DECLINED] = Json::Int64(toSecondsSinceEpoch(declined));
242 0 : json[ConversationMapKeys::DECLINED_MS] = Json::Int64(toMillisecondsSinceEpoch(declined));
243 : }
244 16 : for (const auto& [key, value] : metadatas) {
245 8 : json[ConversationMapKeys::METADATAS][key] = value;
246 : }
247 8 : return json;
248 0 : }
249 :
250 : std::map<std::string, std::string>
251 310 : ConversationRequest::toMap() const
252 : {
253 310 : auto result = metadatas;
254 620 : result[ConversationMapKeys::ID] = conversationId;
255 620 : result[ConversationMapKeys::FROM] = from;
256 310 : if (declined != TimePoint {})
257 3 : result[ConversationMapKeys::DECLINED] = std::to_string(toSecondsSinceEpoch(declined));
258 930 : result[ConversationMapKeys::RECEIVED] = std::to_string(toSecondsSinceEpoch(received));
259 310 : return result;
260 0 : }
261 :
262 : void
263 28 : ConversationRequest::msgpack_unpack(const msgpack::object& o)
264 : {
265 28 : if (o.type != msgpack::type::MAP)
266 0 : throw msgpack::type_error();
267 28 : int64_t receivedSec = 0, declinedSec = 0;
268 28 : std::optional<int64_t> receivedMs, declinedMs;
269 222 : for (uint32_t i = 0; i < o.via.map.size; ++i) {
270 194 : const auto& kv = o.via.map.ptr[i];
271 194 : if (kv.key.type != msgpack::type::STR)
272 0 : continue;
273 194 : std::string_view key(kv.key.via.str.ptr, kv.key.via.str.size);
274 194 : if (key == ConversationMapKeys::FROM)
275 28 : kv.val.convert(from);
276 166 : else if (key == ConversationMapKeys::CONVERSATIONID)
277 28 : kv.val.convert(conversationId);
278 138 : else if (key == ConversationMapKeys::METADATAS)
279 28 : kv.val.convert(metadatas);
280 110 : else if (key == ConversationMapKeys::RECEIVED)
281 28 : kv.val.convert(receivedSec);
282 82 : else if (key == ConversationMapKeys::DECLINED)
283 28 : kv.val.convert(declinedSec);
284 54 : else if (key == ConversationMapKeys::RECEIVED_MS)
285 27 : receivedMs = kv.val.as<int64_t>();
286 27 : else if (key == ConversationMapKeys::DECLINED_MS)
287 27 : declinedMs = kv.val.as<int64_t>();
288 : }
289 28 : received = resolveTimePoint(receivedMs, receivedSec);
290 28 : declined = resolveTimePoint(declinedMs, declinedSec);
291 28 : }
292 :
293 : void
294 0 : ConversationRequest::msgpack_object(msgpack::object* o, msgpack::zone& z) const
295 : {
296 0 : int64_t receivedSec = toSecondsSinceEpoch(received);
297 0 : int64_t declinedSec = toSecondsSinceEpoch(declined);
298 0 : int64_t receivedMs = toMillisecondsSinceEpoch(received);
299 0 : int64_t declinedMs = toMillisecondsSinceEpoch(declined);
300 0 : msgpack::type::make_define_map(ConversationMapKeys::FROM,
301 0 : from,
302 : ConversationMapKeys::CONVERSATIONID,
303 0 : conversationId,
304 : ConversationMapKeys::METADATAS,
305 0 : metadatas,
306 : ConversationMapKeys::RECEIVED,
307 : receivedSec,
308 : ConversationMapKeys::DECLINED,
309 : declinedSec,
310 : ConversationMapKeys::RECEIVED_MS,
311 : receivedMs,
312 : ConversationMapKeys::DECLINED_MS,
313 : declinedMs)
314 0 : .msgpack_object(o, z);
315 0 : }
316 :
317 : using MessageList = std::list<std::shared_ptr<libjami::SwarmMessage>>;
318 :
319 : struct History
320 : {
321 : // While loading the history, we need to avoid:
322 : // - reloading history (can just be ignored)
323 : // - adding new commits (should wait for history to be loaded)
324 : std::mutex mutex {};
325 : std::condition_variable cv {};
326 : bool loading {false};
327 : MessageList messageList {};
328 : std::map<std::string, std::shared_ptr<libjami::SwarmMessage>> quickAccess {};
329 : std::map<std::string, std::list<std::shared_ptr<libjami::SwarmMessage>>> pendingEditions {};
330 : std::map<std::string, std::list<std::map<std::string, std::string>>> pendingReactions {};
331 : };
332 :
333 : class Conversation::Impl
334 : {
335 : private:
336 505 : Impl(std::unique_ptr<ConversationRepository>&& repository,
337 : const std::shared_ptr<JamiAccount>& account,
338 : std::vector<ConversationCommit>&& commits = {})
339 505 : : repository_(repository ? std::move(repository) : throw std::logic_error("Invalid repository"))
340 492 : , account_(account)
341 492 : , accountId_(account->getAccountID())
342 492 : , userId_(account->getUsername())
343 984 : , deviceId_(account->currentDeviceId())
344 492 : , swarmManager_(std::make_shared<SwarmManager>(
345 492 : NodeId(deviceId_),
346 492 : account->isMobile(),
347 492 : Manager::instance().getSeededRandomEngine(),
348 492 : [account = account_](const DeviceId& deviceId) {
349 338 : if (auto acc = account.lock()) {
350 338 : return acc->isConnectedWith(deviceId);
351 337 : }
352 0 : return false;
353 : },
354 492 : repository_->id(),
355 984 : [account = account_, conversationId = repository_->id(), deviceId = NodeId(deviceId_)]()
356 : -> std::optional<MobileNodeInfo> {
357 0 : auto acc = account.lock();
358 0 : if (!acc || !acc->isMobile())
359 0 : return std::nullopt;
360 0 : const auto& identity = acc->identity();
361 0 : if (!identity.first || !identity.second || !identity.second->issuer)
362 0 : return std::nullopt;
363 :
364 0 : const auto now = std::chrono::system_clock::now();
365 0 : const auto certificateExpiry = identity.second->getExpiration();
366 0 : const auto maximumExpiry = now + MAX_MOBILE_LEASE_DURATION;
367 0 : const auto expiry = std::min(certificateExpiry, maximumExpiry);
368 0 : if (expiry <= now)
369 0 : return std::nullopt;
370 :
371 0 : MobileLease lease {1,
372 0 : conversationId,
373 0 : identity.second->issuer->getId(),
374 : deviceId,
375 0 : toSecondsSinceEpoch(now),
376 0 : toSecondsSinceEpoch(expiry),
377 0 : {}};
378 0 : lease.signature = identity.first->sign(mobileLeasePayload(lease));
379 0 : return MobileNodeInfo {deviceId, std::move(lease)};
380 0 : },
381 0 : [this](const dht::InfoHash& issuerId) {
382 : const auto members
383 0 : = repository_->memberUris("", {MemberRole::INVITED, MemberRole::BANNED, MemberRole::LEFT});
384 0 : return members.contains(issuerId.toString());
385 0 : },
386 0 : [account = account_](const NodeId& deviceId) -> std::shared_ptr<dht::crypto::Certificate> {
387 0 : if (auto acc = account.lock())
388 0 : return acc->certStore().getCertificate(deviceId.toString());
389 0 : return {};
390 : },
391 0 : [account = account_](const NodeId& deviceId,
392 : std::function<void(const std::shared_ptr<dht::crypto::Certificate>&)>&& cb) {
393 0 : if (auto acc = account.lock())
394 0 : acc->findCertificate(deviceId, std::move(cb));
395 : else
396 0 : cb(nullptr);
397 0 : }))
398 492 : , transferManager_(std::make_shared<TransferManager>(accountId_,
399 : "",
400 492 : repository_->id(),
401 492 : Manager::instance().getSeededRandomEngine()))
402 492 : , repoPath_(fileutils::get_data_dir() / accountId_ / "conversations" / repository_->id())
403 492 : , conversationDataPath_(fileutils::get_data_dir() / accountId_ / "conversation_data" / repository_->id())
404 492 : , fetchedPath_(conversationDataPath_ / ConversationDirectories::FETCHED)
405 492 : , sendingPath_(conversationDataPath_ / ConversationDirectories::SENDING)
406 492 : , preferencesPath_(conversationDataPath_ / ConversationDirectories::PREFERENCES)
407 492 : , statusPath_(conversationDataPath_ / ConversationDirectories::STATUS)
408 492 : , mobileNodesPath_(conversationDataPath_ / ConversationDirectories::MOBILE_NODES)
409 492 : , hostedCallsPath_(conversationDataPath_ / ConversationDirectories::HOSTED_CALLS)
410 492 : , activeCallsPath_(conversationDataPath_ / ConversationDirectories::ACTIVE_CALLS)
411 492 : , ioContext_(Manager::instance().ioContext())
412 2965 : , typers_(std::make_shared<Typers>(account, repository_->id()))
413 : {
414 492 : if (!commits.empty())
415 237 : initActiveCalls(repository_->convCommitsToMap(commits));
416 492 : loadStatus();
417 : // Restore mobility knowledge: in a swarm made of mostly mobile
418 : // devices, gossip alone is unavailable after a restart (nobody is
419 : // connected), so the persisted list is the only way to know which
420 : // devices need a wake-up notification.
421 492 : loadMobileNodes();
422 : // Self-contained callback: the swarm manager can outlive this Impl
423 : // (its timers hold shared_from_this), so do not capture `this`.
424 984 : swarmManager_->onMobileNodeInfosChanged(
425 984 : [path = mobileNodesPath_](const std::vector<MobileNodeInfo>& mobileNodes) {
426 0 : std::lock_guard lk {dhtnet::fileutils::getFileLock(path)};
427 0 : std::ofstream file(path, std::ios::trunc | std::ios::binary);
428 0 : msgpack::pack(file, mobileNodes);
429 0 : });
430 492 : setupMemberCallback();
431 531 : }
432 :
433 250 : Impl(std::pair<std::unique_ptr<ConversationRepository>, std::vector<ConversationCommit>>&& repoAndCommits,
434 : const std::shared_ptr<JamiAccount>& account)
435 250 : : Impl(std::move(repoAndCommits.first), account, std::move(repoAndCommits.second))
436 237 : {}
437 :
438 : public:
439 221 : Impl(const std::shared_ptr<JamiAccount>& account, ConversationMode mode, const std::string& otherMember = "")
440 221 : : Impl(ConversationRepository::createConversation(account, mode, otherMember), account)
441 221 : {}
442 :
443 34 : Impl(const std::shared_ptr<JamiAccount>& account, const std::string& conversationId)
444 34 : : Impl(std::make_unique<ConversationRepository>(account, conversationId), account)
445 34 : {}
446 :
447 254 : Impl(const std::shared_ptr<JamiAccount>& account, const std::string& remoteDevice, const std::string& conversationId)
448 254 : : Impl(ConversationRepository::cloneConversation(account, remoteDevice, conversationId), account)
449 237 : {}
450 :
451 3100 : std::string toString() const
452 : {
453 12399 : return fmt::format(FMT_COMPILE("[Account {}] [Conversation {}]"), accountId_, repository_->id());
454 : }
455 :
456 0 : std::vector<std::map<std::string, std::string>> getConnectivity() const
457 : {
458 0 : return swarmManager_->getRoutingTableInfo();
459 : }
460 :
461 : mutable std::string fmtStr_;
462 :
463 492 : ~Impl() { stopTracking(); }
464 :
465 : /**
466 : * If, for whatever reason, the daemon is stopped while hosting a conference,
467 : * we need to announce the end of this call when restarting.
468 : * To avoid to keep active calls forever.
469 : */
470 : std::vector<std::string> commitsEndedCalls();
471 : bool isAdmin() const;
472 :
473 378 : void announce(const std::string& commitId, bool commitFromSelf = false)
474 : {
475 378 : std::vector<std::string> vec;
476 378 : if (!commitId.empty())
477 375 : vec.emplace_back(commitId);
478 378 : announce(vec, commitFromSelf);
479 378 : }
480 :
481 395 : void announce(const std::vector<std::string>& commits, bool commitFromSelf = false)
482 : {
483 395 : std::vector<ConversationCommit> convcommits;
484 395 : convcommits.reserve(commits.size());
485 804 : for (const auto& cid : commits) {
486 409 : if (auto commit = repository_->getCommit(cid)) {
487 409 : convcommits.emplace_back(*commit);
488 409 : }
489 : }
490 396 : announce(repository_->convCommitsToMap(convcommits), commitFromSelf);
491 395 : }
492 :
493 : /**
494 : * Initialize activeCalls_ from the list of commits in the repository
495 : * @param commits Commits in reverse chronological order (i.e. from newest to oldest)
496 : */
497 237 : void initActiveCalls(const std::vector<std::map<std::string, std::string>>& commits) const
498 : {
499 237 : std::unordered_set<std::string> invalidHostUris;
500 237 : std::unordered_set<std::string> invalidCallIds;
501 :
502 237 : std::lock_guard lk(activeCallsMtx_);
503 1356 : for (const auto& commit : commits) {
504 2238 : if (commit.at(CommitKey::TYPE) == CommitType::MEMBER) {
505 : // Each commit of type MEMBER has an "action" field whose value can be one
506 : // of the following: "add", "join", "remove", "ban", "unban"
507 : // In the case of "remove" and "ban", we need to add the member's URI to
508 : // invalidHostUris to ensure that any call they may have started in the past
509 : // is no longer considered active.
510 : // For the other actions, there's no harm in adding the member's URI anyway,
511 : // since it's not possible to start hosting a call before joining the swarm (or
512 : // before getting unbanned in the case of previously banned members).
513 1662 : invalidHostUris.emplace(commit.at(CommitKey::URI));
514 867 : } else if (commit.find(CommitKey::CONF_ID) != commit.end() && commit.find(CommitKey::URI) != commit.end()
515 579 : && commit.find(CommitKey::DEVICE) != commit.end()) {
516 : // The commit indicates either the end or the beginning of a call
517 : // (depending on whether there is a "duration" field or not).
518 1 : auto convId = repository_->id();
519 2 : auto confId = commit.at(CommitKey::CONF_ID);
520 2 : auto uri = commit.at(CommitKey::URI);
521 1 : auto device = commit.at(CommitKey::DEVICE);
522 :
523 3 : if (commit.find(CommitKey::DURATION) == commit.end()
524 1 : && invalidCallIds.find(confId) == invalidCallIds.end()
525 3 : && invalidHostUris.find(uri) == invalidHostUris.end()) {
526 1 : std::map<std::string, std::string> activeCall;
527 2 : activeCall["id"] = confId;
528 2 : activeCall["uri"] = uri;
529 1 : activeCall["device"] = device;
530 1 : activeCalls_.emplace_back(activeCall);
531 1 : JAMI_LOG("swarm:{} new active call detected: {} (on device {}, account {})",
532 : convId,
533 : confId,
534 : device,
535 : uri);
536 1 : }
537 : // Even if the call was active, we still add its ID to invalidCallIds to make sure it
538 : // doesn't get added a second time. (This shouldn't happen normally, but in practice
539 : // there are sometimes multiple commits indicating the beginning of the same call.)
540 1 : invalidCallIds.emplace(confId);
541 1 : }
542 : }
543 237 : saveActiveCalls();
544 237 : emitSignal<libjami::ConfigurationSignal::ActiveCallsChanged>(accountId_, repository_->id(), activeCalls_);
545 237 : }
546 :
547 : /**
548 : * Update activeCalls_ via announced commits (in load or via new commits)
549 : * @param commit Commit to check
550 : * @param eraseOnly If we want to ignore added commits
551 : * @param emitSig If we want to emit to client
552 : * @note eraseOnly is used by loadMessages. This is a fail-safe, this SHOULD NOT happen
553 : */
554 82 : void updateActiveCalls(const std::map<std::string, std::string>& commit,
555 : bool eraseOnly = false,
556 : bool emitSig = true) const
557 : {
558 82 : if (!repository_)
559 0 : return;
560 164 : if (commit.at(CommitKey::TYPE) == CommitType::MEMBER) {
561 : // In this case, we need to check if we are not removing a hosting member or device
562 22 : std::lock_guard lk(activeCallsMtx_);
563 22 : auto it = activeCalls_.begin();
564 22 : auto updateActives = false;
565 22 : while (it != activeCalls_.end()) {
566 0 : if (it->at("uri") == commit.at(CommitKey::URI) || it->at("device") == commit.at(CommitKey::URI)) {
567 0 : JAMI_DEBUG("Removing {:s} from the active calls, because {:s} left",
568 : it->at("id"),
569 : commit.at(CommitKey::URI));
570 0 : it = activeCalls_.erase(it);
571 0 : updateActives = true;
572 : } else {
573 0 : ++it;
574 : }
575 : }
576 22 : if (updateActives) {
577 0 : saveActiveCalls();
578 0 : if (emitSig)
579 0 : emitSignal<libjami::ConfigurationSignal::ActiveCallsChanged>(accountId_,
580 0 : repository_->id(),
581 0 : activeCalls_);
582 : }
583 22 : return;
584 22 : }
585 : // Else, it's a call information
586 339 : if (commit.find(CommitKey::CONF_ID) != commit.end() && commit.find(CommitKey::URI) != commit.end()
587 279 : && commit.find(CommitKey::DEVICE) != commit.end()) {
588 53 : auto convId = repository_->id();
589 106 : auto confId = commit.at(CommitKey::CONF_ID);
590 106 : auto uri = commit.at(CommitKey::URI);
591 53 : auto device = commit.at(CommitKey::DEVICE);
592 53 : std::lock_guard lk(activeCallsMtx_);
593 53 : auto itActive = std::find_if(activeCalls_.begin(), activeCalls_.end(), [&](const auto& value) {
594 154 : return value.at("id") == confId && value.at("uri") == uri && value.at("device") == device;
595 : });
596 159 : if (commit.find(CommitKey::DURATION) == commit.end()) {
597 31 : if (itActive == activeCalls_.end() && !eraseOnly) {
598 31 : JAMI_DEBUG("swarm:{:s} new current call detected: {:s} on device {:s}, account {:s}",
599 : convId,
600 : confId,
601 : device,
602 : uri);
603 155 : activeCalls_.emplace_back(std::map<std::string, std::string> {
604 : {"id", confId},
605 : {"uri", uri},
606 : {"device", device},
607 124 : });
608 31 : saveActiveCalls();
609 31 : if (emitSig)
610 31 : emitSignal<libjami::ConfigurationSignal::ActiveCallsChanged>(accountId_,
611 31 : repository_->id(),
612 31 : activeCalls_);
613 : }
614 : } else {
615 22 : if (itActive != activeCalls_.end()) {
616 22 : itActive = activeCalls_.erase(itActive);
617 : // Unlikely, but we must ensure that no duplicate exists
618 44 : while (itActive != activeCalls_.end()) {
619 0 : itActive = std::find_if(itActive, activeCalls_.end(), [&](const auto& value) {
620 0 : return value.at("id") == confId && value.at("uri") == uri && value.at("device") == device;
621 : });
622 0 : if (itActive != activeCalls_.end()) {
623 0 : JAMI_ERROR("Duplicate call found. (This is a bug)");
624 0 : itActive = activeCalls_.erase(itActive);
625 : }
626 : }
627 :
628 22 : if (eraseOnly) {
629 0 : JAMI_WARNING("previous swarm:{:s} call finished detected: {:s} on device "
630 : "{:s}, account {:s}",
631 : convId,
632 : confId,
633 : device,
634 : uri);
635 : } else {
636 22 : JAMI_DEBUG("swarm:{:s} call finished: {:s} on device {:s}, account {:s}",
637 : convId,
638 : confId,
639 : device,
640 : uri);
641 : }
642 : }
643 22 : saveActiveCalls();
644 22 : if (emitSig)
645 22 : emitSignal<libjami::ConfigurationSignal::ActiveCallsChanged>(accountId_,
646 22 : repository_->id(),
647 22 : activeCalls_);
648 : }
649 53 : }
650 31 : }
651 :
652 1378 : void announce(const std::vector<std::map<std::string, std::string>>& commits, bool commitFromSelf = false)
653 : {
654 1378 : if (!repository_)
655 0 : return;
656 1378 : auto convId = repository_->id();
657 1378 : if (repository_->mode() == ConversationMode::DOCUMENT) {
658 : // A document's commits are not messages: nothing here goes to the
659 : // clients' conversation views or to the plugins. What a merge brought
660 : // in -- checkpoints, attachments, a rename -- is replayed into the
661 : // live CRDT session instead. Member events still feed the internal
662 : // callback so the swarm's view of who to sync with stays fresh.
663 60 : bool memberEvent = false;
664 120 : for (const auto& c : commits)
665 120 : memberEvent |= c.at(CommitKey::TYPE) == CommitType::MEMBER;
666 60 : if (memberEvent && onMembersChanged_)
667 32 : onMembersChanged_(repository_->memberUris("", {}));
668 : // Nothing to replay for our own commits: what this device wrote came
669 : // out of the live session in the first place. From another thread:
670 : // announce() can run under writeMtx_ (a pull holds it) and the replay
671 : // asks the module for the conversation, which takes locks of its own.
672 60 : if (!commits.empty() && !commitFromSelf)
673 19 : dht::ThreadPool::io().run([w = account_, parentId = repository_->parentConversationId(), convId] {
674 19 : if (auto acc = w.lock())
675 19 : if (auto collab = acc->collaborativeEditing())
676 38 : collab->onRepositoryUpdated(parentId, convId);
677 19 : });
678 60 : return;
679 : }
680 1318 : auto ok = !commits.empty();
681 2637 : auto lastId = ok ? commits.rbegin()->at(ConversationMapKeys::ID) : "";
682 1317 : addToHistory(loadedHistory_, commits, true, commitFromSelf);
683 1317 : if (ok) {
684 1315 : bool announceMember = false;
685 2686 : for (const auto& c : commits) {
686 : // Announce member events
687 2746 : if (c.at(CommitKey::TYPE) == CommitType::MEMBER) {
688 4933 : if (c.find(CommitKey::URI) != c.end() && c.find(CommitKey::ACTION) != c.end()) {
689 1973 : const auto& uri = c.at(CommitKey::URI);
690 987 : const auto& actionStr = c.at(CommitKey::ACTION);
691 987 : auto action = -1;
692 987 : if (actionStr == CommitAction::ADD)
693 470 : action = 0;
694 517 : else if (actionStr == CommitAction::JOIN)
695 493 : action = 1;
696 24 : else if (actionStr == CommitAction::REMOVE)
697 3 : action = 2;
698 21 : else if (actionStr == CommitAction::BAN)
699 19 : action = 3;
700 2 : else if (actionStr == CommitAction::UNBAN)
701 2 : action = 4;
702 987 : if (actionStr == CommitAction::BAN || actionStr == CommitAction::REMOVE) {
703 : // In this case, a potential host was removed during a call.
704 22 : updateActiveCalls(c);
705 22 : typers_->removeTyper(uri);
706 : }
707 987 : if (action != -1) {
708 987 : announceMember = true;
709 987 : emitSignal<libjami::ConversationSignal::ConversationMemberEvent>(accountId_,
710 : convId,
711 : uri,
712 : action);
713 : }
714 : }
715 772 : } else if (c.at(CommitKey::TYPE) == CommitType::CALL_HISTORY) {
716 60 : updateActiveCalls(c);
717 : }
718 : #ifdef ENABLE_PLUGIN
719 1373 : if (auto& pluginChatManager = Manager::instance().getJamiPluginManager().getChatServicesManager();
720 1372 : pluginChatManager.hasHandlers()) {
721 6 : auto cm = std::make_shared<JamiMessage>(accountId_, convId, c.at("author") != userId_, c, false);
722 3 : cm->isSwarm = true;
723 3 : pluginChatManager.publishMessage(cm);
724 :
725 3 : const auto editIt = c.find(CommitKey::EDIT);
726 3 : const bool isEdit = editIt != c.end() && !editIt->second.empty();
727 6 : const bool isText = c.at(CommitKey::TYPE) == CommitType::TEXT;
728 :
729 6 : if (const auto idIt = c.find("id"); idIt != c.end() && (isEdit || isText)) {
730 1 : const std::string msgId = idIt->second;
731 :
732 : // Snapshot under lock.
733 1 : libjami::SwarmMessage snap;
734 : {
735 1 : std::lock_guard const lk(loadedHistory_.mutex);
736 1 : if (auto it = loadedHistory_.quickAccess.find(msgId);
737 1 : it != loadedHistory_.quickAccess.end()) {
738 1 : snap = *it->second;
739 : }
740 1 : }
741 :
742 1 : if (!snap.id.empty()) {
743 : // Transform outside lock.
744 3 : std::vector<libjami::SwarmMessage> single {snap};
745 1 : pluginChatManager.transformSwarmMessages(single, accountId_, convId);
746 0 : auto& transformed = single[0];
747 0 : const auto boIt = transformed.pluginData.find("bodyOverwrite");
748 0 : const bool hasOverwrite = boIt != transformed.pluginData.end() && !boIt->second.empty();
749 :
750 : // Write back + build signal under one lock, emit outside.
751 0 : libjami::SwarmMessage signalMsg;
752 : {
753 0 : std::lock_guard const lk(loadedHistory_.mutex);
754 0 : if (isEdit || hasOverwrite) {
755 0 : signalMsg = injectEditionOverwrites(msgId, hasOverwrite ? boIt->second : "");
756 : } else {
757 0 : if (auto it = loadedHistory_.quickAccess.find(msgId);
758 0 : it != loadedHistory_.quickAccess.end()) {
759 0 : it->second->pluginData["bodyOverwrite"] = "";
760 : }
761 : }
762 0 : }
763 0 : if (!signalMsg.id.empty()) {
764 0 : emitSignal<libjami::ConversationSignal::SwarmMessageUpdated>(accountId_,
765 : convId,
766 : signalMsg);
767 : }
768 0 : }
769 0 : }
770 3 : } else
771 : #endif
772 : {
773 : // No active handlers: still notify clients when an edit arrives.
774 2739 : if (const auto editIt = c.find(CommitKey::EDIT); editIt != c.end() && !editIt->second.empty()) {
775 9 : libjami::SwarmMessage signalMsg;
776 : {
777 9 : std::lock_guard const lk(loadedHistory_.mutex);
778 9 : if (auto originalIt = loadedHistory_.quickAccess.find(editIt->second);
779 9 : originalIt != loadedHistory_.quickAccess.end()) {
780 9 : signalMsg = *originalIt->second;
781 : }
782 9 : }
783 9 : if (!signalMsg.id.empty()) {
784 9 : emitSignal<libjami::ConversationSignal::SwarmMessageUpdated>(accountId_, convId, signalMsg);
785 : }
786 9 : }
787 : }
788 : }
789 :
790 1314 : if (announceMember && onMembersChanged_) {
791 986 : onMembersChanged_(repository_->memberUris("", {}));
792 : }
793 : }
794 1379 : }
795 :
796 492 : void loadStatus()
797 : {
798 : try {
799 : // read file
800 982 : auto file = fileutils::loadFile(statusPath_);
801 : // load values
802 2 : msgpack::object_handle oh = msgpack::unpack((const char*) file.data(), file.size());
803 2 : std::lock_guard lk {messageStatusMtx_};
804 2 : oh.get().convert(messagesStatus_);
805 492 : } catch (const std::exception& e) {
806 490 : }
807 492 : }
808 1787 : void saveStatus()
809 : {
810 1787 : std::ofstream file(statusPath_, std::ios::trunc | std::ios::binary);
811 1787 : msgpack::pack(file, messagesStatus_);
812 1787 : }
813 :
814 492 : void loadMobileNodes()
815 : {
816 : try {
817 984 : auto file = fileutils::loadFile(mobileNodesPath_);
818 0 : msgpack::object_handle oh = msgpack::unpack((const char*) file.data(), file.size());
819 : try {
820 0 : std::vector<MobileNodeInfo> nodes;
821 0 : oh.get().convert(nodes);
822 0 : swarmManager_->setMobileNodes(nodes);
823 0 : } catch (const std::exception&) {
824 0 : std::vector<NodeId> nodes;
825 0 : oh.get().convert(nodes);
826 0 : swarmManager_->setMobileNodes(nodes);
827 0 : }
828 492 : } catch (const std::exception& e) {
829 492 : return;
830 492 : }
831 : }
832 :
833 18 : void loadActiveCalls() const
834 : {
835 : try {
836 : // read file
837 29 : auto file = fileutils::loadFile(activeCallsPath_);
838 : // load values
839 7 : msgpack::object_handle oh = msgpack::unpack((const char*) file.data(), file.size());
840 7 : std::lock_guard lk {activeCallsMtx_};
841 7 : oh.get().convert(activeCalls_);
842 18 : } catch (const std::exception& e) {
843 11 : return;
844 11 : }
845 : }
846 :
847 308 : void saveActiveCalls() const
848 : {
849 308 : std::ofstream file(activeCallsPath_, std::ios::trunc | std::ios::binary);
850 308 : msgpack::pack(file, activeCalls_);
851 308 : }
852 :
853 18 : void loadHostedCalls() const
854 : {
855 : try {
856 : // read file
857 30 : auto file = fileutils::loadFile(hostedCallsPath_);
858 : // load values
859 6 : msgpack::object_handle oh = msgpack::unpack((const char*) file.data(), file.size());
860 6 : std::lock_guard lk {activeCallsMtx_};
861 6 : oh.get().convert(hostedCalls_);
862 18 : } catch (const std::exception& e) {
863 12 : return;
864 12 : }
865 : }
866 :
867 42 : void saveHostedCalls() const
868 : {
869 42 : std::ofstream file(hostedCallsPath_, std::ios::trunc | std::ios::binary);
870 42 : msgpack::pack(file, hostedCalls_);
871 42 : }
872 :
873 : void voteUnban(const std::string& contactUri, const std::string_view type, const OnDoneCb& cb);
874 :
875 : std::vector<std::map<std::string, std::string>> getMembers(bool includeInvited,
876 : bool includeLeft,
877 : bool includeBanned) const;
878 :
879 : std::vector<std::map<std::string, std::string>> getTrackedMembers() const;
880 :
881 6575 : std::string_view memberBanType(const std::string& uri) const
882 : {
883 6575 : auto crt = fmt::format("{}.crt", uri);
884 6575 : auto bannedMember = repoPath_ / MemberPath::BANNED / MemberPath::MEMBERS / crt;
885 6574 : if (std::filesystem::is_regular_file(bannedMember))
886 23 : return "members"sv;
887 6553 : auto bannedAdmin = repoPath_ / MemberPath::BANNED / MemberPath::ADMINS / crt;
888 6551 : if (std::filesystem::is_regular_file(bannedAdmin))
889 0 : return "admins"sv;
890 6553 : auto bannedInvited = repoPath_ / MemberPath::BANNED / MemberPath::INVITED / uri;
891 6552 : if (std::filesystem::is_regular_file(bannedInvited))
892 2 : return "invited"sv;
893 6550 : return {};
894 6575 : }
895 :
896 7241 : bool isDeviceBanned(const std::string& deviceId) const
897 : {
898 7241 : auto crt = fmt::format("{}.crt", deviceId);
899 7242 : auto bannedDevice = repoPath_ / MemberPath::BANNED / MemberPath::DEVICES / crt;
900 14480 : return std::filesystem::is_regular_file(bannedDevice);
901 7242 : }
902 :
903 4678 : std::shared_ptr<dhtnet::ChannelSocket> gitSocket(const DeviceId& deviceId) const
904 : {
905 4678 : std::lock_guard lk(gitSocketMtx_);
906 4678 : auto deviceSockets = gitSocketList_.find(deviceId);
907 9358 : return (deviceSockets != gitSocketList_.end()) ? deviceSockets->second.get() : nullptr;
908 4679 : }
909 :
910 2055 : void addGitSocket(const DeviceId& deviceId, const std::shared_ptr<dhtnet::ChannelSocket>& socket)
911 : {
912 2055 : GitSocket replaced;
913 : {
914 2055 : std::lock_guard lk(gitSocketMtx_);
915 2055 : auto& slot = gitSocketList_[deviceId];
916 : // Re-registering the channel we already own must not close it.
917 2055 : if (slot.get() == socket)
918 1028 : return;
919 1027 : replaced = std::move(slot);
920 1027 : slot = socket;
921 2055 : }
922 : // Closing the replaced channel, if any, happens here, outside the lock.
923 2055 : }
924 989 : void removeGitSocket(const DeviceId& deviceId, const std::shared_ptr<dhtnet::ChannelSocket>& expected = {})
925 : {
926 989 : GitSocket socket;
927 : {
928 989 : std::lock_guard lk(gitSocketMtx_);
929 989 : auto deviceSockets = gitSocketList_.find(deviceId);
930 988 : if (deviceSockets == gitSocketList_.end())
931 460 : return;
932 : // A dead channel must not evict the one that replaced it.
933 529 : if (expected && deviceSockets->second.get() != expected)
934 0 : return;
935 527 : socket = std::move(deviceSockets->second);
936 528 : gitSocketList_.erase(deviceSockets);
937 988 : }
938 : // Closing the channel outside the lock tells the peer to stop serving it, and wakes up
939 : // any fetch blocked reading from it, which would otherwise hold
940 : // ConversationRepository::opMtx_ until its read times out.
941 988 : }
942 :
943 : void disconnectFromDevice(const DeviceId& deviceId);
944 :
945 : /**
946 : * Remove all git sockets and all DRT nodes associated with the given peer.
947 : * This is used when a swarm member is banned to ensure that we stop syncing
948 : * with them or sending them message notifications.
949 : */
950 : void disconnectFromPeer(const std::string& peerUri);
951 :
952 : std::vector<std::map<std::string, std::string>> getMembers(bool includeInvited, bool includeLeft) const;
953 :
954 : std::function<void()> bootstrapCb_;
955 : std::mutex bootstrapMtx_ {};
956 : #ifdef LIBJAMI_TEST
957 : std::function<void(std::string, BootstrapStatus)> bootstrapCbTest_;
958 : #endif
959 :
960 : std::mutex writeMtx_ {};
961 : const std::unique_ptr<ConversationRepository> repository_;
962 : const std::weak_ptr<JamiAccount> account_;
963 : const std::string accountId_;
964 : const std::string userId_;
965 : const std::string deviceId_;
966 : const std::shared_ptr<SwarmManager> swarmManager_;
967 : std::atomic_bool isRemoving_ {false};
968 : std::vector<libjami::SwarmMessage> loadMessages(const LogOptions& options, History* optHistory = nullptr);
969 : void loadMissingBodyOverwrites();
970 : void reloadBodyOverwriteMessages();
971 :
972 : // Out-of-order calls are safe: only the latest edition's overwrite propagates to the original.
973 : void updateMessageBodyOverwrite(const std::string& messageId, std::string_view bodyOverwrite);
974 : // Must be called with loadedHistory_.mutex held.
975 : // Stores bodyOverwrite on messageId, propagates to original if edition, returns signal message.
976 : // Only propagates when messageId == originalMsg->latestEditionId, so out-of-order calls are safe.
977 : libjami::SwarmMessage injectEditionOverwrites(const std::string& messageId, std::string_view bodyOverwrite);
978 :
979 : void clearBodyOverwrites();
980 : void pull(const std::string& deviceId);
981 :
982 : // Avoid multiple fetch/merges at the same time.
983 : std::mutex pullcbsMtx_ {};
984 : // store current remote in fetch
985 : std::map<std::string, std::deque<std::pair<std::string, OnPullCb>>> fetchingRemotes_ {};
986 : const std::shared_ptr<TransferManager> transferManager_ {};
987 : const std::filesystem::path repoPath_ {};
988 : const std::filesystem::path conversationDataPath_ {};
989 : const std::filesystem::path fetchedPath_ {};
990 :
991 : // Manage last message displayed and status
992 : const std::filesystem::path sendingPath_ {};
993 : const std::filesystem::path preferencesPath_ {};
994 : const std::filesystem::path statusPath_ {};
995 :
996 : // Devices known to be mobile, persisted so that wake-up notifications
997 : // can be sent right after a restart, before any DRT gossip is received
998 : const std::filesystem::path mobileNodesPath_ {};
999 :
1000 : OnMembersChanged onMembersChanged_ {};
1001 : struct TrackedMember
1002 : {
1003 : std::set<DeviceId> devices;
1004 : std::set<DeviceId> failedDevices;
1005 : };
1006 : std::map<std::string, TrackedMember> trackedMembers_;
1007 : mutable std::mutex trackedMembersMtx_;
1008 : bool isTracking_ {false};
1009 : void setupMemberCallback();
1010 : void startTracking(std::weak_ptr<Conversation> w);
1011 : void stopTracking();
1012 : void rotateTrackedMembers(const std::string& memberUri = "", const DeviceId& deviceId = {});
1013 : void monitorConnection(std::weak_ptr<Conversation> w);
1014 : void onConnectionFailed(const DeviceId& deviceId, const std::string& memberUri = "");
1015 :
1016 : uint64_t presenceDeviceListenerToken_ {0};
1017 :
1018 : // Manage hosted calls on this device
1019 : std::filesystem::path hostedCallsPath_ {};
1020 : mutable std::map<std::string, uint64_t /* start time */> hostedCalls_ {};
1021 : // Manage active calls for this conversation (can be hosted by other devices)
1022 : std::filesystem::path activeCallsPath_ {};
1023 : mutable std::mutex activeCallsMtx_ {};
1024 : mutable std::vector<std::map<std::string, std::string>> activeCalls_ {};
1025 :
1026 : mutable std::mutex gitSocketMtx_ {};
1027 : GitSocketList gitSocketList_ {};
1028 :
1029 : // Bootstrap
1030 : const std::shared_ptr<asio::io_context> ioContext_;
1031 :
1032 : /**
1033 : * Loaded history represents the linearized history to show for clients
1034 : */
1035 : History loadedHistory_ {};
1036 : std::vector<std::shared_ptr<libjami::SwarmMessage>> addToHistory(
1037 : History& history,
1038 : const std::vector<std::map<std::string, std::string>>& commits,
1039 : bool messageReceived = false,
1040 : bool commitFromSelf = false);
1041 :
1042 : void handleReaction(History& history, const std::shared_ptr<libjami::SwarmMessage>& sharedCommit) const;
1043 : void handleEdition(History& history,
1044 : const std::shared_ptr<libjami::SwarmMessage>& sharedCommit,
1045 : bool messageReceived) const;
1046 : bool handleMessage(History& history,
1047 : const std::shared_ptr<libjami::SwarmMessage>& sharedCommit,
1048 : bool messageReceived) const;
1049 : void rectifyStatus(const std::shared_ptr<libjami::SwarmMessage>& message, History& history) const;
1050 : /**
1051 : * {uri, {
1052 : * {"fetch", "commitId"},
1053 : * {"fetched_ts", "timestamp"},
1054 : * {"read", "commitId"},
1055 : * {"read_ts", "timestamp"}
1056 : * }
1057 : * }
1058 : */
1059 : mutable std::mutex messageStatusMtx_;
1060 : std::function<void(const std::map<std::string, std::map<std::string, std::string>>&)> messageStatusCb_ {};
1061 : std::map<std::string, std::map<std::string, std::string>> messagesStatus_ {};
1062 : /**
1063 : * Status: 0 = commited, 1 = fetched, 2 = read
1064 : * This cache the curent status to add in the messages
1065 : */
1066 : // Note: only store int32_t cause it's easy to pass to dbus this way
1067 : // memberToStatus serves as a cache for loading messages
1068 : std::map<std::string, int32_t> memberToStatus;
1069 :
1070 : // futureStatus is used to store the status for receiving messages
1071 : // (because we're not sure to fetch the commit before receiving a status change for this)
1072 : std::map<std::string, std::map<std::string, int32_t>> futureStatus;
1073 : // Update internal structures regarding status
1074 : void updateStatus(const std::string& uri,
1075 : libjami::Account::MessageStates status,
1076 : const std::string& commitId,
1077 : const std::string& ts,
1078 : bool emit = false);
1079 :
1080 : std::shared_ptr<Typers> typers_;
1081 : };
1082 :
1083 : /**
1084 : * Stores bodyOverwrite on messageId, propagates to original if messageId is an edition commit,
1085 : * and returns the message to signal (original if edition, messageId's message otherwise),
1086 : * with each edition entry's "bodyOverwrite" injected from the corresponding commit's pluginData.
1087 : * Must be called with loadedHistory_.mutex held.
1088 : * @param messageId ID of the edition commit or original message to update.
1089 : * @param bodyOverwrite New overwrite value; empty string marks the message as processed with no overwrite.
1090 : */
1091 : libjami::SwarmMessage
1092 0 : Conversation::Impl::injectEditionOverwrites(const std::string& messageId, const std::string_view bodyOverwrite)
1093 : {
1094 0 : const auto it = loadedHistory_.quickAccess.find(messageId);
1095 0 : if (it == loadedHistory_.quickAccess.end()) {
1096 0 : return {};
1097 : }
1098 :
1099 0 : libjami::SwarmMessage* signalTarget = it->second.get();
1100 :
1101 0 : if (const auto editIt = it->second->body.find(CommitKey::EDIT); editIt != it->second->body.end()) {
1102 0 : it->second->pluginData["bodyOverwrite"] = bodyOverwrite;
1103 0 : if (editIt->second.empty()) {
1104 0 : return {};
1105 : }
1106 0 : const auto originalIt = loadedHistory_.quickAccess.find(editIt->second);
1107 0 : if (originalIt == loadedHistory_.quickAccess.end()) {
1108 0 : return {};
1109 : }
1110 0 : signalTarget = originalIt->second.get();
1111 0 : } else if (it->second->editions.empty()) {
1112 0 : it->second->pluginData["bodyOverwrite"] = bodyOverwrite;
1113 : }
1114 :
1115 0 : if (const auto msgEditionIt = std::ranges::find_if(signalTarget->editions,
1116 0 : [&](const auto& e) {
1117 0 : const auto idIt = e.find("id");
1118 0 : return idIt != e.end() && idIt->second == messageId;
1119 : });
1120 0 : msgEditionIt != signalTarget->editions.end()) {
1121 0 : (*msgEditionIt)["bodyOverwrite"] = bodyOverwrite;
1122 : }
1123 :
1124 0 : if (const auto latestEdIt = loadedHistory_.quickAccess.find(signalTarget->latestEditionId);
1125 0 : latestEdIt != loadedHistory_.quickAccess.end()) {
1126 0 : if (const auto latestEdBoIt = latestEdIt->second->pluginData.find("bodyOverwrite");
1127 0 : latestEdBoIt != latestEdIt->second->pluginData.end() && !latestEdBoIt->second.empty()) {
1128 0 : signalTarget->pluginData["bodyOverwrite"] = latestEdBoIt->second;
1129 : }
1130 : }
1131 0 : return *signalTarget;
1132 0 : }
1133 :
1134 : void
1135 492 : Conversation::Impl::setupMemberCallback()
1136 : {
1137 492 : repository_->onMembersChanged([this](const std::set<std::string>& memberUris) {
1138 : {
1139 1235 : std::lock_guard lk(trackedMembersMtx_);
1140 1235 : if (isTracking_) {
1141 298 : if (auto acc = account_.lock()) {
1142 633 : for (auto it = trackedMembers_.begin(); it != trackedMembers_.end();) {
1143 335 : if (memberUris.find(it->first) == memberUris.end()) {
1144 7 : acc->presenceManager()->untrackBuddy(it->first);
1145 7 : it = trackedMembers_.erase(it);
1146 : } else {
1147 328 : ++it;
1148 : }
1149 : }
1150 298 : }
1151 : }
1152 1235 : }
1153 :
1154 1236 : if (onMembersChanged_)
1155 1235 : onMembersChanged_(memberUris);
1156 1236 : });
1157 492 : }
1158 :
1159 : void
1160 682 : Conversation::Impl::startTracking(std::weak_ptr<Conversation> w)
1161 : {
1162 682 : auto acc = account_.lock();
1163 682 : if (!acc)
1164 0 : return;
1165 :
1166 : {
1167 682 : std::lock_guard lk(trackedMembersMtx_);
1168 682 : if (isTracking_)
1169 172 : return;
1170 510 : isTracking_ = true;
1171 1020 : presenceDeviceListenerToken_ = acc->presenceManager()->addDeviceListener(
1172 1020 : [w](const std::string& uri, const DeviceId& deviceId, bool online) {
1173 849 : if (auto sthis = w.lock()) {
1174 849 : if (online && sthis->isMember(uri)) {
1175 2022 : sthis->addKnownDevices({deviceId}, uri);
1176 : }
1177 849 : }
1178 849 : });
1179 682 : }
1180 :
1181 1020 : rotateTrackedMembers();
1182 682 : }
1183 :
1184 : void
1185 778 : Conversation::Impl::stopTracking()
1186 : {
1187 : // Collect data under trackedMembersMtx_, then release it before calling
1188 : // into PresenceManager to avoid lock-ordering inversion with the
1189 : // PresenceManager mutex (which is held when notifyListeners calls
1190 : // addKnownDevices, which in turn acquires trackedMembersMtx_).
1191 778 : uint64_t token = 0;
1192 778 : std::vector<std::string> urisToUntrack;
1193 : {
1194 778 : std::lock_guard lk(trackedMembersMtx_);
1195 778 : if (!isTracking_)
1196 268 : return;
1197 510 : isTracking_ = false;
1198 510 : token = presenceDeviceListenerToken_;
1199 510 : presenceDeviceListenerToken_ = 0;
1200 1439 : for (const auto& [uri, _] : trackedMembers_) {
1201 929 : urisToUntrack.push_back(uri);
1202 : }
1203 510 : trackedMembers_.clear();
1204 778 : }
1205 :
1206 510 : auto acc = account_.lock();
1207 510 : if (!acc)
1208 217 : return;
1209 :
1210 293 : if (token) {
1211 293 : acc->presenceManager()->removeDeviceListener(token);
1212 : }
1213 :
1214 902 : for (const auto& uri : urisToUntrack) {
1215 610 : acc->presenceManager()->untrackBuddy(uri);
1216 : }
1217 995 : }
1218 :
1219 : void
1220 879 : Conversation::Impl::rotateTrackedMembers(const std::string& memberUri, const DeviceId& deviceId)
1221 : {
1222 879 : auto acc = account_.lock();
1223 879 : if (!acc)
1224 0 : return;
1225 :
1226 879 : std::lock_guard lk(trackedMembersMtx_);
1227 879 : if (!isTracking_)
1228 298 : return;
1229 :
1230 581 : if (!memberUri.empty()) {
1231 71 : if (auto it = trackedMembers_.find(memberUri); it != trackedMembers_.end()) {
1232 69 : JAMI_WARNING("{} [device {}] Rotating tracked members after connection failure", toString(), deviceId);
1233 69 : auto& info = it->second;
1234 69 : info.failedDevices.insert(deviceId);
1235 69 : if (std::includes(info.failedDevices.begin(),
1236 : info.failedDevices.end(),
1237 : info.devices.begin(),
1238 : info.devices.end())) {
1239 37 : acc->presenceManager()->untrackBuddy(it->first);
1240 37 : trackedMembers_.erase(it);
1241 : }
1242 : } else {
1243 2 : return;
1244 : }
1245 : }
1246 :
1247 579 : auto members = repository_->members();
1248 579 : size_t N = members.size();
1249 579 : size_t K = std::min(N, 3 + (size_t) std::log2(N));
1250 :
1251 : // If we are already trying enough devices, don't rotate
1252 579 : auto activeDevices = swarmManager_->getActiveNodesCount();
1253 579 : if (activeDevices >= 2 * K)
1254 0 : return;
1255 :
1256 579 : JAMI_WARNING("{} Refreshing tracked members: {}/{} active ({}/{} devices)",
1257 : toString(),
1258 : trackedMembers_.size(),
1259 : K,
1260 : activeDevices,
1261 : 2 * K);
1262 :
1263 : // Add new members if we have space
1264 579 : if (trackedMembers_.size() < K) {
1265 547 : std::vector<std::string> candidates;
1266 547 : candidates.reserve(N - trackedMembers_.size());
1267 1799 : for (const auto& m : members) {
1268 1252 : if (m.uri != memberUri && trackedMembers_.find(m.uri) == trackedMembers_.end()) {
1269 1174 : candidates.push_back(m.uri);
1270 : }
1271 : }
1272 547 : if (!candidates.empty()) {
1273 512 : std::vector<std::string> chosen;
1274 512 : std::sample(candidates.begin(),
1275 : candidates.end(),
1276 : std::back_inserter(chosen),
1277 512 : K - trackedMembers_.size(),
1278 512 : Manager::instance().getSeededRandomEngine());
1279 1485 : for (const auto& uri : chosen) {
1280 973 : acc->presenceManager()->trackBuddy(uri);
1281 973 : trackedMembers_.emplace(uri, TrackedMember {});
1282 : }
1283 512 : }
1284 547 : }
1285 1179 : }
1286 :
1287 : void
1288 369 : Conversation::Impl::onConnectionFailed(const DeviceId& deviceId, const std::string& memberUri)
1289 : {
1290 369 : rotateTrackedMembers(memberUri, deviceId);
1291 369 : }
1292 :
1293 : void
1294 634 : Conversation::Impl::monitorConnection(std::weak_ptr<Conversation> w)
1295 : {
1296 634 : if (!swarmManager_->isConnected()) {
1297 623 : startTracking(w);
1298 : }
1299 :
1300 634 : swarmManager_->onConnectionChanged([w](bool ok) {
1301 345 : dht::ThreadPool::io().run([w, ok] {
1302 345 : if (auto sthis = w.lock()) {
1303 : // Check the current connection state rather than relying on the
1304 : // captured `ok` value, because the thread pool does not guarantee
1305 : // execution order. Two rapid state changes (connected then
1306 : // disconnected) could otherwise cause stopTracking to run after
1307 : // startTracking, leaving the conversation untracked while the DRT
1308 : // has no connected nodes.
1309 345 : if (sthis->pimpl_->swarmManager_->isConnected()) {
1310 286 : sthis->pimpl_->stopTracking();
1311 : } else {
1312 59 : sthis->pimpl_->startTracking(w);
1313 : }
1314 345 : if (ok) {
1315 287 : if (sthis->pimpl_->bootstrapCb_)
1316 287 : sthis->pimpl_->bootstrapCb_();
1317 : }
1318 : #ifdef LIBJAMI_TEST
1319 345 : if (sthis->pimpl_->bootstrapCbTest_)
1320 40 : sthis->pimpl_->bootstrapCbTest_(sthis->id(),
1321 20 : ok ? BootstrapStatus::SUCCESS : BootstrapStatus::FAILED);
1322 : #endif
1323 345 : }
1324 345 : });
1325 345 : });
1326 634 : }
1327 :
1328 : bool
1329 22 : Conversation::Impl::isAdmin() const
1330 : {
1331 22 : auto adminsPath = repoPath_ / MemberPath::ADMINS;
1332 44 : return std::filesystem::is_regular_file(fileutils::getFullPath(adminsPath, userId_ + ".crt"));
1333 22 : }
1334 :
1335 : void
1336 13 : Conversation::Impl::disconnectFromDevice(const DeviceId& deviceId)
1337 : {
1338 26 : swarmManager_->deleteNode({deviceId});
1339 :
1340 13 : GitSocket socket;
1341 : {
1342 13 : std::lock_guard lk(gitSocketMtx_);
1343 13 : if (auto it = gitSocketList_.find(deviceId); it != gitSocketList_.end()) {
1344 13 : socket = std::move(it->second);
1345 13 : gitSocketList_.erase(it);
1346 : }
1347 13 : }
1348 : // The channel is closed here, outside the lock.
1349 13 : }
1350 :
1351 : void
1352 19 : Conversation::Impl::disconnectFromPeer(const std::string& peerUri)
1353 : {
1354 19 : std::set<DeviceId> devicesToRemove;
1355 :
1356 19 : const auto nodes = swarmManager_->getAllNodes();
1357 29 : for (const auto node : nodes)
1358 10 : if (peerUri == repository_->uriFromDevice(node.toString()))
1359 5 : devicesToRemove.emplace(node);
1360 :
1361 : {
1362 19 : std::lock_guard lk(gitSocketMtx_);
1363 42 : for (const auto& [deviceId, socket] : gitSocketList_) {
1364 23 : auto cert = socket ? socket->peerCertificate() : nullptr;
1365 46 : if ((cert && cert->issuer && cert->issuer->getId().toString() == peerUri)
1366 46 : || peerUri == repository_->uriFromDevice(deviceId.toString())) {
1367 12 : devicesToRemove.emplace(deviceId);
1368 : }
1369 23 : }
1370 19 : }
1371 :
1372 31 : for (const auto& deviceId : devicesToRemove)
1373 12 : disconnectFromDevice(deviceId);
1374 19 : }
1375 :
1376 : std::vector<std::map<std::string, std::string>>
1377 133 : Conversation::Impl::getMembers(bool includeInvited, bool includeLeft, bool includeBanned) const
1378 : {
1379 133 : std::vector<std::map<std::string, std::string>> result;
1380 133 : auto members = repository_->members();
1381 133 : std::lock_guard lk(messageStatusMtx_);
1382 401 : for (const auto& member : members) {
1383 268 : if (member.role == MemberRole::BANNED && !includeBanned) {
1384 0 : continue;
1385 : }
1386 268 : if (member.role == MemberRole::INVITED && !includeInvited)
1387 0 : continue;
1388 268 : if (member.role == MemberRole::LEFT && !includeLeft)
1389 0 : continue;
1390 268 : auto mm = member.map();
1391 268 : auto it = messagesStatus_.find(member.uri);
1392 267 : if (it != messagesStatus_.end()) {
1393 386 : auto readIt = it->second.find("read");
1394 193 : if (readIt != it->second.end())
1395 393 : mm[ConversationMapKeys::LAST_DISPLAYED] = readIt->second;
1396 : }
1397 267 : result.emplace_back(std::move(mm));
1398 268 : }
1399 266 : return result;
1400 133 : }
1401 :
1402 : std::vector<std::map<std::string, std::string>>
1403 0 : Conversation::Impl::getTrackedMembers() const
1404 : {
1405 0 : std::vector<std::map<std::string, std::string>> result;
1406 0 : std::lock_guard lk(trackedMembersMtx_);
1407 0 : for (const auto& [uri, member] : trackedMembers_) {
1408 0 : std::map<std::string, std::string> map;
1409 0 : map["uri"] = uri;
1410 0 : std::string devicesStr;
1411 0 : for (const auto& dev : member.devices) {
1412 0 : if (!devicesStr.empty())
1413 0 : devicesStr += ";";
1414 0 : devicesStr += dev.toString();
1415 : }
1416 0 : map["devices"] = devicesStr;
1417 0 : result.emplace_back(std::move(map));
1418 0 : }
1419 0 : return result;
1420 0 : }
1421 :
1422 : std::vector<std::string>
1423 18 : Conversation::Impl::commitsEndedCalls()
1424 : {
1425 : // Handle current calls
1426 18 : std::vector<std::string> commits {};
1427 18 : std::unique_lock lk(writeMtx_);
1428 18 : std::unique_lock lkA(activeCallsMtx_);
1429 18 : for (const auto& hostedCall : hostedCalls_) {
1430 : // In this case, this means that we left
1431 : // the conference while still hosting it, so activeCalls
1432 : // will not be correctly updated
1433 : // We don't need to send notifications there, as peers will sync with presence
1434 0 : auto confId = hostedCall.first;
1435 0 : auto now = std::chrono::system_clock::now();
1436 0 : uint64_t nowConverted = std::chrono::duration_cast<std::chrono::seconds>(now.time_since_epoch()).count();
1437 0 : auto duration = (nowConverted > hostedCall.second) ? (nowConverted - hostedCall.second) * 1000 : 0;
1438 0 : auto commitMessage = CommitMessage::conferenceHostingEnd(confId, deviceId_, userId_, duration);
1439 :
1440 0 : auto itActive = std::find_if(activeCalls_.begin(),
1441 : activeCalls_.end(),
1442 0 : [this, confId = hostedCall.first](const auto& value) {
1443 0 : return value.at("id") == confId && value.at("uri") == userId_
1444 0 : && value.at("device") == deviceId_;
1445 : });
1446 0 : if (itActive != activeCalls_.end())
1447 0 : activeCalls_.erase(itActive);
1448 0 : commits.emplace_back(repository_->commitMessage(commitMessage.toString()));
1449 0 : JAMI_DEBUG("Removing hosted conference... {:s}", hostedCall.first);
1450 0 : }
1451 18 : hostedCalls_.clear();
1452 18 : saveActiveCalls();
1453 18 : saveHostedCalls();
1454 36 : return commits;
1455 18 : }
1456 :
1457 : std::vector<libjami::SwarmMessage>
1458 2458 : Conversation::Impl::loadMessages(const LogOptions& options, History* optHistory)
1459 : {
1460 2458 : auto history = optHistory ? optHistory : &loadedHistory_;
1461 :
1462 : // history->mutex is locked by the caller
1463 2458 : if (!repository_ || history->loading) {
1464 0 : return {};
1465 : }
1466 2458 : history->loading = true;
1467 :
1468 : // By convention, if options.nbOfCommits is zero, then we
1469 : // don't impose a limit on the number of commits returned.
1470 2458 : bool limitNbOfCommits = options.nbOfCommits > 0;
1471 :
1472 2458 : auto startLogging = options.from == "";
1473 2458 : auto breakLogging = false;
1474 2458 : auto currentHistorySize = loadedHistory_.messageList.size();
1475 2458 : std::vector<std::string> replies;
1476 2458 : std::vector<std::shared_ptr<libjami::SwarmMessage>> msgList;
1477 4916 : repository_->log(
1478 : /* preCondition */
1479 4916 : [&](const auto& id, const auto& author, const auto& commit) {
1480 18652 : if (options.skipMerge && git_commit_parentcount(commit.get()) > 1) {
1481 4 : return CallbackResult::Skip;
1482 : }
1483 18647 : if (id == options.to) {
1484 1032 : if (options.includeTo)
1485 0 : breakLogging = true; // For the next commit
1486 : }
1487 18631 : if (replies.empty()) { // This avoid load until
1488 : // NOTE: in the future, we may want to add "Reply-Body" in commit to avoid to load
1489 : // until this commit
1490 18642 : if ((limitNbOfCommits
1491 18642 : && (loadedHistory_.messageList.size() - currentHistorySize) == options.nbOfCommits))
1492 2 : return CallbackResult::Break; // Stop logging
1493 18640 : if (breakLogging)
1494 0 : return CallbackResult::Break; // Stop logging
1495 18640 : if (id == options.to && !options.includeTo) {
1496 1031 : return CallbackResult::Break; // Stop logging
1497 : }
1498 : }
1499 :
1500 17601 : if (!startLogging && options.from != "" && options.from == id)
1501 1771 : startLogging = true;
1502 17606 : if (!startLogging)
1503 27 : return CallbackResult::Skip; // Start logging after this one
1504 :
1505 17579 : if (options.fastLog) {
1506 16328 : if (options.authorUri != "") {
1507 1 : if (options.authorUri == repository_->uriFromDevice(author.email)) {
1508 1 : return CallbackResult::Break; // Found author, stop
1509 : }
1510 : }
1511 : }
1512 :
1513 17576 : return CallbackResult::Ok; // Continue
1514 : },
1515 : /* emplaceCb */
1516 4916 : [&](auto&& cc) {
1517 17586 : if (limitNbOfCommits && (msgList.size() == options.nbOfCommits))
1518 581 : return;
1519 17008 : auto optMessage = repository_->convCommitToMap(cc);
1520 17002 : if (!optMessage.has_value())
1521 3 : return;
1522 16991 : auto message = optMessage.value();
1523 50984 : if (message.find(CommitKey::REPLY_TO) != message.end()) {
1524 1 : auto it = std::find(replies.begin(), replies.end(), message.at(CommitKey::REPLY_TO));
1525 1 : if (it == replies.end()) {
1526 3 : replies.emplace_back(message.at(CommitKey::REPLY_TO));
1527 : }
1528 : }
1529 16987 : auto it = std::find(replies.begin(), replies.end(), message.at("id"));
1530 16996 : if (it != replies.end()) {
1531 1 : replies.erase(it);
1532 : }
1533 16998 : std::shared_ptr<libjami::SwarmMessage> firstMsg;
1534 16998 : if ((history == &loadedHistory_) && msgList.empty() && !loadedHistory_.messageList.empty()) {
1535 0 : firstMsg = *loadedHistory_.messageList.rbegin();
1536 : }
1537 51009 : auto added = addToHistory(*history, {message}, false, false);
1538 17004 : if (!added.empty() && firstMsg) {
1539 0 : emitSignal<libjami::ConversationSignal::SwarmMessageUpdated>(accountId_, repository_->id(), *firstMsg);
1540 : }
1541 17004 : msgList.insert(msgList.end(), added.begin(), added.end());
1542 34008 : },
1543 : /* postCondition */
1544 0 : [&](auto, auto, auto) {
1545 : // Stop logging if there was a limit set on the number of commits
1546 : // to return and we reached it. This isn't strictly necessary since
1547 : // the check at the beginning of `emplaceCb` ensures that we won't
1548 : // return too many messages, but it prevents us from needlessly
1549 : // iterating over a (potentially) large number of commits.
1550 17570 : return limitNbOfCommits && (msgList.size() == options.nbOfCommits);
1551 : },
1552 2458 : options.from,
1553 2458 : options.logIfNotFound);
1554 :
1555 2458 : history->loading = false;
1556 2458 : history->cv.notify_all();
1557 :
1558 : // Convert for client (remove ptr)
1559 2458 : std::vector<libjami::SwarmMessage> ret;
1560 2458 : ret.reserve(msgList.size());
1561 19447 : for (const auto& msg : msgList) {
1562 16995 : ret.emplace_back(*msg);
1563 : }
1564 2455 : return ret;
1565 2456 : }
1566 :
1567 : void
1568 3 : Conversation::Impl::handleReaction(History& history, const std::shared_ptr<libjami::SwarmMessage>& sharedCommit) const
1569 : {
1570 6 : auto it = history.quickAccess.find(sharedCommit->body.at(CommitKey::REACT_TO));
1571 3 : auto peditIt = history.pendingEditions.find(sharedCommit->id);
1572 3 : if (peditIt != history.pendingEditions.end()) {
1573 0 : auto oldBody = sharedCommit->body;
1574 0 : sharedCommit->body[CommitKey::BODY] = peditIt->second.front()->body[CommitKey::BODY];
1575 0 : if (sharedCommit->body.at(CommitKey::BODY).empty())
1576 0 : return;
1577 0 : history.pendingEditions.erase(peditIt);
1578 0 : }
1579 3 : if (it != history.quickAccess.end()) {
1580 3 : it->second->reactions.emplace_back(sharedCommit->body);
1581 3 : emitSignal<libjami::ConversationSignal::ReactionAdded>(accountId_,
1582 3 : repository_->id(),
1583 3 : it->second->id,
1584 3 : sharedCommit->body);
1585 : } else {
1586 0 : history.pendingReactions[sharedCommit->body.at(CommitKey::REACT_TO)].emplace_back(sharedCommit->body);
1587 : }
1588 : }
1589 :
1590 : void
1591 12 : Conversation::Impl::handleEdition(History& history,
1592 : const std::shared_ptr<libjami::SwarmMessage>& sharedCommit,
1593 : bool messageReceived) const
1594 : {
1595 24 : auto editId = sharedCommit->body.at(CommitKey::EDIT);
1596 12 : auto it = history.quickAccess.find(editId);
1597 12 : if (it != history.quickAccess.end()) {
1598 9 : auto baseCommit = it->second;
1599 9 : if (baseCommit) {
1600 27 : auto itReact = baseCommit->body.find(CommitKey::REACT_TO);
1601 9 : std::string toReplace = (baseCommit->type == CommitType::DATA_TRANSFER) ? CommitKey::TID : CommitKey::BODY;
1602 : // An edition need not carry the field it replaces: retiring a
1603 : // collaborative document says nothing but which commit it retires.
1604 : // Absent means empty, which is what an edition to nothing already
1605 : // means everywhere below.
1606 9 : std::string body;
1607 9 : if (auto itBody = sharedCommit->body.find(toReplace); itBody != sharedCommit->body.end())
1608 7 : body = itBody->second;
1609 : // Edit reaction
1610 9 : if (itReact != baseCommit->body.end()) {
1611 1 : baseCommit->body[toReplace] = body; // Replace body if pending
1612 1 : it = history.quickAccess.find(itReact->second);
1613 1 : auto itPending = history.pendingReactions.find(itReact->second);
1614 1 : if (it != history.quickAccess.end()) {
1615 1 : baseCommit = it->second; // Base commit
1616 1 : auto itPreviousReact = std::find_if(baseCommit->reactions.begin(),
1617 1 : baseCommit->reactions.end(),
1618 1 : [&](const auto& reaction) {
1619 3 : return reaction.at("id") == editId;
1620 : });
1621 1 : if (itPreviousReact != baseCommit->reactions.end()) {
1622 1 : (*itPreviousReact)[toReplace] = body;
1623 1 : if (body.empty()) {
1624 1 : baseCommit->reactions.erase(itPreviousReact);
1625 2 : emitSignal<libjami::ConversationSignal::ReactionRemoved>(accountId_,
1626 1 : repository_->id(),
1627 1 : baseCommit->id,
1628 : editId);
1629 : }
1630 : }
1631 0 : } else if (itPending != history.pendingReactions.end()) {
1632 : // Else edit if pending
1633 0 : auto itReaction = std::find_if(itPending->second.begin(),
1634 0 : itPending->second.end(),
1635 0 : [&](const auto& reaction) { return reaction.at("id") == editId; });
1636 0 : if (itReaction != itPending->second.end()) {
1637 0 : (*itReaction)[toReplace] = body;
1638 0 : if (body.empty())
1639 0 : itPending->second.erase(itReaction);
1640 : }
1641 : } else {
1642 : // Add to pending edtions
1643 0 : messageReceived ? history.pendingEditions[editId].emplace_front(sharedCommit)
1644 0 : : history.pendingEditions[editId].emplace_back(sharedCommit);
1645 : }
1646 : } else {
1647 : // Normal message
1648 8 : auto editionBody = it->second->body;
1649 : // Tag the superseded body with the commit id that introduced it, so the
1650 : // client can look up per-edition plugin overwrites via quickAccess.
1651 16 : editionBody["id"] = it->second->latestEditionId.empty() ? it->second->id : it->second->latestEditionId;
1652 8 : it->second->editions.emplace(it->second->editions.begin(), std::move(editionBody));
1653 : #ifdef ENABLE_PLUGIN
1654 16 : if (const auto boIt = it->second->pluginData.find("bodyOverwrite");
1655 8 : boIt != it->second->pluginData.end()) {
1656 0 : it->second->editions.front()["bodyOverwrite"] = boIt->second;
1657 : }
1658 : #endif
1659 8 : it->second->body[toReplace] = sharedCommit->body[toReplace];
1660 8 : it->second->latestEditionId = sharedCommit->id;
1661 8 : if (toReplace == CommitKey::TID) {
1662 : // Avoid to replace fileId in client
1663 6 : it->second->body["fileId"] = "";
1664 : }
1665 : // Remove reactions
1666 8 : if (sharedCommit->body.at(toReplace).empty())
1667 5 : it->second->reactions.clear();
1668 8 : }
1669 9 : }
1670 9 : } else {
1671 3 : messageReceived ? history.pendingEditions[editId].emplace_front(sharedCommit)
1672 3 : : history.pendingEditions[editId].emplace_back(sharedCommit);
1673 : }
1674 12 : }
1675 :
1676 : bool
1677 18331 : Conversation::Impl::handleMessage(History& history,
1678 : const std::shared_ptr<libjami::SwarmMessage>& sharedCommit,
1679 : bool messageReceived) const
1680 : {
1681 18331 : if (messageReceived) {
1682 : // For a received message, we place it at the beginning of the list
1683 1326 : if (!history.messageList.empty())
1684 1027 : sharedCommit->linearizedParent = (*history.messageList.begin())->id;
1685 1326 : history.messageList.emplace_front(sharedCommit);
1686 : } else {
1687 : // For a loaded message, we load from newest to oldest
1688 : // So we change the parent of the last message.
1689 17005 : if (!history.messageList.empty())
1690 14572 : (*history.messageList.rbegin())->linearizedParent = sharedCommit->id;
1691 17001 : history.messageList.emplace_back(sharedCommit);
1692 : }
1693 : // Handle pending reactions/editions
1694 18331 : auto reactIt = history.pendingReactions.find(sharedCommit->id);
1695 18332 : if (reactIt != history.pendingReactions.end()) {
1696 0 : for (const auto& commitBody : reactIt->second)
1697 0 : sharedCommit->reactions.emplace_back(commitBody);
1698 0 : history.pendingReactions.erase(reactIt);
1699 : }
1700 18337 : auto peditIt = history.pendingEditions.find(sharedCommit->id);
1701 18334 : if (peditIt != history.pendingEditions.end()) {
1702 0 : auto oldBody = sharedCommit->body;
1703 : // Tag original body with its own message id for client identification.
1704 0 : oldBody["id"] = sharedCommit->id;
1705 0 : sharedCommit->latestEditionId = peditIt->second.front()->id;
1706 0 : if (sharedCommit->type == CommitType::DATA_TRANSFER) {
1707 0 : sharedCommit->body[CommitKey::TID] = peditIt->second.front()->body[CommitKey::TID];
1708 0 : sharedCommit->body["fileId"] = "";
1709 : } else {
1710 0 : sharedCommit->body[CommitKey::BODY] = peditIt->second.front()->body[CommitKey::BODY];
1711 : }
1712 0 : peditIt->second.pop_front();
1713 0 : for (const auto& commit : peditIt->second) {
1714 0 : auto edBody = commit->body;
1715 : // Tag each superseded body with its edition commit id, mirroring
1716 : // handleEdition so injectEditionOverwrites can find plugin overwrites.
1717 0 : edBody["id"] = commit->id;
1718 0 : sharedCommit->editions.emplace_back(std::move(edBody));
1719 0 : }
1720 0 : sharedCommit->editions.emplace_back(std::move(oldBody));
1721 0 : history.pendingEditions.erase(peditIt);
1722 0 : }
1723 : // Announce to client
1724 18329 : if (messageReceived)
1725 1325 : emitSignal<libjami::ConversationSignal::SwarmMessageReceived>(accountId_, repository_->id(), *sharedCommit);
1726 18336 : return !messageReceived;
1727 : }
1728 :
1729 : void
1730 18352 : Conversation::Impl::rectifyStatus(const std::shared_ptr<libjami::SwarmMessage>& message, History& history) const
1731 : {
1732 18352 : auto parentIt = history.quickAccess.find(message->linearizedParent);
1733 18353 : auto currentMessage = message;
1734 :
1735 30384 : while (parentIt != history.quickAccess.end()) {
1736 12094 : const auto& parent = parentIt->second;
1737 12371 : for (const auto& [peer, value] : message->status) {
1738 11204 : auto parentStatusIt = parent->status.find(peer);
1739 11131 : if (parentStatusIt == parent->status.end() || parentStatusIt->second < value) {
1740 290 : parent->status[peer] = value;
1741 580 : emitSignal<libjami::ConfigurationSignal::AccountMessageStatusChanged>(accountId_,
1742 290 : repository_->id(),
1743 : peer,
1744 290 : parent->id,
1745 : value);
1746 10926 : } else if (parentStatusIt->second >= value) {
1747 10930 : break;
1748 : }
1749 : }
1750 12116 : currentMessage = parent;
1751 12127 : parentIt = history.quickAccess.find(parent->linearizedParent);
1752 : }
1753 18349 : }
1754 :
1755 : std::vector<std::shared_ptr<libjami::SwarmMessage>>
1756 18346 : Conversation::Impl::addToHistory(History& history,
1757 : const std::vector<std::map<std::string, std::string>>& commits,
1758 : bool messageReceived,
1759 : bool commitFromSelf)
1760 : {
1761 : //
1762 : // NOTE: This function makes the following assumptions on its arguments:
1763 : // - The messages in "history" are in reverse chronological order (newest message
1764 : // first, oldest message last).
1765 : // - If messageReceived is true, then the commits in "commits" are assumed to be in
1766 : // chronological order (oldest to newest) and to be newer than the ones in "history".
1767 : // They are therefore inserted at the beginning of the message list.
1768 : // - If messageReceived is false, then the commits in "commits" are assumed to be in
1769 : // reverse chronological order (newest to oldest) and to be older than the ones in
1770 : // "history". They are therefore appended at the end of the message list.
1771 : //
1772 18346 : auto acc = account_.lock();
1773 18345 : if (!acc)
1774 0 : return {};
1775 18346 : auto username = acc->getUsername();
1776 18332 : if (messageReceived && (&history == &loadedHistory_ && history.loading)) {
1777 0 : std::unique_lock lk(history.mutex);
1778 0 : history.cv.wait(lk, [&] { return !history.loading; });
1779 0 : }
1780 :
1781 : // Only set messages' status on history for client
1782 18332 : bool needToSetMessageStatus = !commitFromSelf && &history == &loadedHistory_;
1783 :
1784 18332 : std::vector<std::shared_ptr<libjami::SwarmMessage>> sharedCommits;
1785 : // Apply the document removals of this batch before anything else in it.
1786 : //
1787 : // A removal is always newer than the announcement it retires, but the batch is
1788 : // walked oldest first when messages come in and newest first when older ones
1789 : // are paged back in. Acting in batch order would therefore recreate the
1790 : // repository of every deleted document each time the user scrolls up.
1791 36719 : for (const auto& commit : commits) {
1792 18398 : auto typeIt = commit.find(CommitKey::TYPE);
1793 18386 : if (typeIt == commit.end() || typeIt->second != CommitType::COLLAB_DOC)
1794 18384 : continue;
1795 56 : auto editIt = commit.find(CommitKey::EDIT);
1796 56 : if (editIt == commit.end() || editIt->second.empty())
1797 53 : continue;
1798 : // A removal names no document of its own: which one it retires is read from
1799 : // the announcement it edits, the only commit the swarm ties to its author.
1800 : // Otherwise a member could retire somebody else's document.
1801 3 : if (auto announcement = repository_->getCommit(editIt->second)) {
1802 3 : const auto& docId = announcement->commitMsg.uri;
1803 3 : if (!docId.empty())
1804 3 : acc->collaborativeEditing()->onDocumentRemoved(repository_->id(), docId);
1805 3 : }
1806 : }
1807 36714 : for (const auto& commit : commits) {
1808 18390 : auto commitId = commit.at("id");
1809 18388 : if (history.quickAccess.find(commitId) != history.quickAccess.end())
1810 1 : continue; // Already present
1811 18398 : auto typeIt = commit.find(CommitKey::TYPE);
1812 : // Nothing to show for the client, skip
1813 18386 : if (typeIt != commit.end() && typeIt->second == CommitType::MERGE)
1814 44 : continue;
1815 : // A collaborative document is announced here, but its content lives in a
1816 : // separate repository: make sure that repository exists locally so it can
1817 : // be replicated. The commit itself falls through and is displayed like a
1818 : // shared file.
1819 18347 : if (typeIt != commit.end() && typeIt->second == CommitType::COLLAB_DOC) {
1820 : // Removals were applied above, before any announcement of this batch.
1821 56 : auto editIt = commit.find(CommitKey::EDIT);
1822 56 : bool isRemoval = editIt != commit.end() && !editIt->second.empty();
1823 56 : if (!isRemoval) {
1824 106 : if (auto uriIt = commit.find(CommitKey::URI); uriIt != commit.end() && !uriIt->second.empty())
1825 53 : acc->collaborativeEditing()->onDocumentAnnounced(repository_->id(), uriIt->second);
1826 : }
1827 : }
1828 :
1829 18352 : auto sharedCommit = std::make_shared<libjami::SwarmMessage>();
1830 18351 : sharedCommit->fromMapStringString(commit);
1831 :
1832 18347 : if (needToSetMessageStatus) {
1833 977 : std::lock_guard lk(messageStatusMtx_);
1834 : // Check if we already have status information for the commit.
1835 978 : auto itFuture = futureStatus.find(sharedCommit->id);
1836 977 : if (itFuture != futureStatus.end()) {
1837 13 : sharedCommit->status = std::move(itFuture->second);
1838 13 : futureStatus.erase(itFuture);
1839 : }
1840 976 : }
1841 18348 : sharedCommits.emplace_back(sharedCommit);
1842 18403 : }
1843 :
1844 18331 : if (needToSetMessageStatus) {
1845 971 : constexpr int32_t SENDING = static_cast<int32_t>(libjami::Account::MessageStates::SENDING);
1846 971 : constexpr int32_t SENT = static_cast<int32_t>(libjami::Account::MessageStates::SENT);
1847 971 : constexpr int32_t DISPLAYED = static_cast<int32_t>(libjami::Account::MessageStates::DISPLAYED);
1848 :
1849 971 : std::lock_guard lk(messageStatusMtx_);
1850 13683 : for (const auto& member : repository_->members()) {
1851 : // For each member, we iterate over the commits to add in reverse chronological
1852 : // order (i.e. from newest to oldest) and set their status from the point of view
1853 : // of that member (as best we can given the information we have).
1854 : //
1855 : // The key assumption made in order to compute the status is that it can never decrease
1856 : // (with respect to the ordering SENDING < SENT < DISPLAYED) as we go back in time. We
1857 : // therefore start by setting the "status" variable below to the lowest possible value,
1858 : // and increase it when we encounter a commit for which it is justified to do so.
1859 : //
1860 : // If messageReceived is true, then the commits we are adding are the most recent in the
1861 : // conversation history, so the lowest possible value is SENDING.
1862 : //
1863 : // If messageReceived is false, then the commits we are adding are older than the ones
1864 : // that are already in the history, so the lowest possible value is the status of the
1865 : // oldest message in the history so far, which is stored in memberToStatus.
1866 12708 : auto status = SENDING;
1867 12708 : if (!messageReceived) {
1868 12 : auto cache = memberToStatus[member.uri];
1869 12 : if (cache > status)
1870 9 : status = cache;
1871 : }
1872 12708 : auto& messagesStatus = messagesStatus_[member.uri];
1873 :
1874 25391 : for (auto it = sharedCommits.rbegin(); it != sharedCommits.rend(); it++) {
1875 12727 : auto sharedCommit = *it;
1876 12712 : auto previousStatus = status;
1877 12712 : auto& commitStatus = sharedCommit->status[member.uri];
1878 :
1879 : // Compute status for the current commit.
1880 38048 : if (status < SENT && messagesStatus["fetched"] == sharedCommit->id) {
1881 12 : status = SENT;
1882 : }
1883 37964 : if (messagesStatus["read"] == sharedCommit->id) {
1884 1 : status = DISPLAYED;
1885 : }
1886 37977 : if (member.uri == sharedCommit->body.at("author")) {
1887 975 : status = DISPLAYED;
1888 : }
1889 12674 : if (status < commitStatus) {
1890 2 : status = commitStatus;
1891 : }
1892 :
1893 : // Store computed value.
1894 12674 : commitStatus = status;
1895 :
1896 : // Update messagesStatus_ if needed.
1897 12674 : if (previousStatus == SENDING && status >= SENT) {
1898 2918 : messagesStatus["fetched"] = sharedCommit->id;
1899 : }
1900 12673 : if (previousStatus <= SENT && status == DISPLAYED) {
1901 2883 : messagesStatus["read"] = sharedCommit->id;
1902 : }
1903 12672 : }
1904 :
1905 12711 : if (!messageReceived) {
1906 : // Update memberToStatus with the status of the last (i.e. oldest) added commit.
1907 12 : memberToStatus[member.uri] = status;
1908 : }
1909 970 : }
1910 971 : }
1911 :
1912 18332 : std::vector<std::shared_ptr<libjami::SwarmMessage>> messages;
1913 36688 : for (const auto& sharedCommit : sharedCommits) {
1914 18355 : history.quickAccess[sharedCommit->id] = sharedCommit;
1915 :
1916 36696 : auto reactToIt = sharedCommit->body.find(CommitKey::REACT_TO);
1917 36676 : auto editIt = sharedCommit->body.find(CommitKey::EDIT);
1918 18328 : if (reactToIt != sharedCommit->body.end() && !reactToIt->second.empty()) {
1919 3 : handleReaction(history, sharedCommit);
1920 18344 : } else if (editIt != sharedCommit->body.end() && !editIt->second.empty()) {
1921 12 : handleEdition(history, sharedCommit, messageReceived);
1922 18329 : } else if (handleMessage(history, sharedCommit, messageReceived)) {
1923 17005 : messages.emplace_back(sharedCommit);
1924 : }
1925 18348 : rectifyStatus(sharedCommit, history);
1926 : }
1927 :
1928 18343 : return messages;
1929 18344 : }
1930 :
1931 221 : Conversation::Conversation(const std::shared_ptr<JamiAccount>& account,
1932 : ConversationMode mode,
1933 221 : const std::string& otherMember)
1934 221 : : pimpl_ {new Impl {account, mode, otherMember}}
1935 221 : {}
1936 :
1937 34 : Conversation::Conversation(const std::shared_ptr<JamiAccount>& account, const std::string& conversationId)
1938 34 : : pimpl_ {new Impl {account, conversationId}}
1939 34 : {}
1940 :
1941 254 : Conversation::Conversation(const std::shared_ptr<JamiAccount>& account,
1942 : const std::string& remoteDevice,
1943 254 : const std::string& conversationId)
1944 254 : : pimpl_ {new Impl {account, remoteDevice, conversationId}}
1945 254 : {}
1946 :
1947 492 : Conversation::~Conversation() {}
1948 :
1949 : std::string
1950 6519 : Conversation::id() const
1951 : {
1952 6519 : return pimpl_->repository_ ? pimpl_->repository_->id() : "";
1953 : }
1954 :
1955 : void
1956 181 : Conversation::addMember(const std::string& contactUri, const OnDoneCb& cb)
1957 : {
1958 : try {
1959 181 : if (mode() == ConversationMode::ONE_TO_ONE) {
1960 : // Only authorize to add left members
1961 1 : auto initialMembers = getInitialMembers();
1962 1 : auto it = std::find(initialMembers.begin(), initialMembers.end(), contactUri);
1963 1 : if (it == initialMembers.end()) {
1964 1 : JAMI_WARNING("Unable to add new member in one to one conversation");
1965 1 : cb(false, "");
1966 1 : return;
1967 : }
1968 1 : }
1969 0 : } catch (const std::exception& e) {
1970 0 : JAMI_WARNING("Unable to get mode: {}", e.what());
1971 0 : cb(false, "");
1972 0 : return;
1973 0 : }
1974 180 : if (isMember(contactUri, true)) {
1975 0 : JAMI_WARNING("Unable to add member {} because it's already a member", contactUri);
1976 0 : cb(false, "");
1977 0 : return;
1978 : }
1979 180 : if (isMemberBanned(contactUri)) {
1980 3 : if (pimpl_->isAdmin()) {
1981 2 : dht::ThreadPool::io().run([w = weak(), contactUri = std::move(contactUri), cb = std::move(cb)] {
1982 2 : if (auto sthis = w.lock()) {
1983 2 : auto members = sthis->pimpl_->repository_->members();
1984 2 : auto type = sthis->pimpl_->memberBanType(contactUri);
1985 2 : if (type.empty()) {
1986 0 : cb(false, {});
1987 0 : return;
1988 : }
1989 2 : sthis->pimpl_->voteUnban(contactUri, type, cb);
1990 4 : }
1991 : });
1992 : } else {
1993 1 : JAMI_WARNING("Unable to add member {} because this member is blocked", contactUri);
1994 2 : cb(false, "");
1995 : }
1996 3 : return;
1997 : }
1998 :
1999 177 : dht::ThreadPool::io().run([w = weak(), contactUri = std::move(contactUri), cb = std::move(cb)] {
2000 177 : if (auto sthis = w.lock()) {
2001 : // Add member files and commit
2002 177 : std::unique_lock lk(sthis->pimpl_->writeMtx_);
2003 177 : auto commit = sthis->pimpl_->repository_->addMember(contactUri);
2004 177 : if (not commit.empty())
2005 177 : sthis->pimpl_->announce(commit, true);
2006 177 : lk.unlock();
2007 177 : if (cb)
2008 177 : cb(!commit.empty(), commit);
2009 354 : }
2010 177 : });
2011 : }
2012 :
2013 : std::shared_ptr<dhtnet::ChannelSocket>
2014 4678 : Conversation::gitSocket(const DeviceId& deviceId) const
2015 : {
2016 4678 : return pimpl_->gitSocket(deviceId);
2017 : }
2018 :
2019 : void
2020 2055 : Conversation::addGitSocket(const DeviceId& deviceId, const std::shared_ptr<dhtnet::ChannelSocket>& socket)
2021 : {
2022 2055 : pimpl_->addGitSocket(deviceId, socket);
2023 2054 : }
2024 :
2025 : void
2026 988 : Conversation::removeGitSocket(const DeviceId& deviceId, const std::shared_ptr<dhtnet::ChannelSocket>& expected)
2027 : {
2028 988 : pimpl_->removeGitSocket(deviceId, expected);
2029 988 : }
2030 :
2031 : void
2032 505 : Conversation::shutdownConnections()
2033 : {
2034 505 : GitSocketList gitSockets;
2035 : {
2036 505 : std::lock_guard lk(pimpl_->gitSocketMtx_);
2037 505 : gitSockets = std::move(pimpl_->gitSocketList_);
2038 505 : pimpl_->gitSocketList_.clear();
2039 505 : }
2040 : // Closing the channels wakes up any fetch currently blocked reading from them.
2041 505 : gitSockets.clear();
2042 505 : if (pimpl_->swarmManager_)
2043 505 : pimpl_->swarmManager_->shutdown();
2044 505 : }
2045 :
2046 : void
2047 0 : Conversation::connectivityChanged()
2048 : {
2049 0 : if (pimpl_->swarmManager_)
2050 0 : pimpl_->swarmManager_->maintainBuckets();
2051 0 : }
2052 :
2053 : std::vector<jami::DeviceId>
2054 0 : Conversation::getDeviceIdList() const
2055 : {
2056 0 : return pimpl_->swarmManager_->getAllNodes();
2057 : }
2058 :
2059 : std::shared_ptr<Typers>
2060 9 : Conversation::typers() const
2061 : {
2062 9 : return pimpl_->typers_;
2063 : }
2064 :
2065 : std::vector<std::map<std::string, std::string>>
2066 0 : Conversation::getConnectivity() const
2067 : {
2068 0 : return pimpl_->getConnectivity();
2069 : }
2070 :
2071 : void
2072 2 : Conversation::Impl::voteUnban(const std::string& contactUri, const std::string_view type, const OnDoneCb& cb)
2073 : {
2074 : // Check if admin
2075 2 : if (!isAdmin()) {
2076 0 : JAMI_WARNING("You're not an admin of this repo. Unable to unblock {}", contactUri);
2077 0 : cb(false, {});
2078 0 : return;
2079 : }
2080 :
2081 : // Vote for removal
2082 2 : std::unique_lock lk(writeMtx_);
2083 2 : auto voteCommit = repository_->voteUnban(contactUri, type);
2084 2 : if (voteCommit.empty()) {
2085 0 : JAMI_WARNING("Unbanning {} failed", contactUri);
2086 0 : cb(false, "");
2087 0 : return;
2088 : }
2089 :
2090 2 : auto lastId = voteCommit;
2091 2 : std::vector<std::string> commits;
2092 2 : commits.emplace_back(voteCommit);
2093 :
2094 : // If admin, check vote
2095 4 : auto resolveCommit = repository_->resolveVote(contactUri, type, CommitAction::UNBAN);
2096 2 : if (!resolveCommit.empty()) {
2097 2 : commits.emplace_back(resolveCommit);
2098 2 : lastId = resolveCommit;
2099 2 : JAMI_WARNING("Vote solved for unbanning {}.", contactUri);
2100 : }
2101 2 : announce(commits, true);
2102 2 : lk.unlock();
2103 2 : if (cb)
2104 2 : cb(!lastId.empty(), lastId);
2105 2 : }
2106 :
2107 : void
2108 17 : Conversation::removeMember(const std::string& contactUri, bool isDevice, const OnDoneCb& cb)
2109 : {
2110 34 : dht::ThreadPool::io().run(
2111 34 : [w = weak(), contactUri = std::move(contactUri), isDevice = std::move(isDevice), cb = std::move(cb)] {
2112 17 : if (auto sthis = w.lock()) {
2113 : // Check if admin
2114 17 : if (!sthis->pimpl_->isAdmin()) {
2115 1 : JAMI_WARNING("You're not an admin of this repo. Unable to block {}", contactUri);
2116 1 : cb(false, {});
2117 2 : return;
2118 : }
2119 :
2120 : // Get current user type
2121 16 : std::string type;
2122 16 : if (isDevice) {
2123 1 : type = "devices";
2124 : } else {
2125 15 : auto members = sthis->pimpl_->repository_->members();
2126 30 : for (const auto& member : members) {
2127 30 : if (member.uri == contactUri) {
2128 15 : if (member.role == MemberRole::INVITED) {
2129 3 : type = "invited";
2130 12 : } else if (member.role == MemberRole::ADMIN) {
2131 1 : type = "admins";
2132 11 : } else if (member.role == MemberRole::MEMBER) {
2133 11 : type = "members";
2134 : }
2135 15 : break;
2136 : }
2137 : }
2138 15 : if (type.empty()) {
2139 0 : cb(false, {});
2140 0 : return;
2141 : }
2142 15 : }
2143 :
2144 : // Vote for removal
2145 16 : std::unique_lock lk(sthis->pimpl_->writeMtx_);
2146 16 : auto voteCommit = sthis->pimpl_->repository_->voteKick(contactUri, type);
2147 16 : if (voteCommit.empty()) {
2148 1 : JAMI_WARNING("Kicking {} failed", contactUri);
2149 2 : cb(false, "");
2150 1 : return;
2151 : }
2152 :
2153 15 : auto lastId = voteCommit;
2154 15 : std::vector<std::string> commits;
2155 15 : commits.emplace_back(voteCommit);
2156 :
2157 : // If admin, check vote
2158 30 : auto resolveCommit = sthis->pimpl_->repository_->resolveVote(contactUri, type, CommitAction::BAN);
2159 15 : if (!resolveCommit.empty()) {
2160 15 : commits.emplace_back(resolveCommit);
2161 15 : lastId = resolveCommit;
2162 15 : JAMI_WARNING("Vote solved for {}. {} banned", contactUri, isDevice ? "Device" : "Member");
2163 15 : if (isDevice)
2164 1 : sthis->pimpl_->disconnectFromDevice(DeviceId(contactUri));
2165 : else
2166 14 : sthis->pimpl_->disconnectFromPeer(contactUri);
2167 : }
2168 :
2169 15 : sthis->pimpl_->announce(commits, true);
2170 15 : lk.unlock();
2171 15 : cb(!lastId.empty(), lastId);
2172 35 : }
2173 : });
2174 17 : }
2175 :
2176 : std::vector<std::map<std::string, std::string>>
2177 133 : Conversation::getMembers(bool includeInvited, bool includeLeft, bool includeBanned) const
2178 : {
2179 133 : return pimpl_->getMembers(includeInvited, includeLeft, includeBanned);
2180 : }
2181 :
2182 : std::vector<std::map<std::string, std::string>>
2183 0 : Conversation::getTrackedMembers() const
2184 : {
2185 0 : return pimpl_->getTrackedMembers();
2186 : }
2187 :
2188 : std::set<std::string>
2189 2729 : Conversation::memberUris(std::string_view filter, const std::set<MemberRole>& filteredRoles) const
2190 : {
2191 2729 : return pimpl_->repository_->memberUris(filter, filteredRoles);
2192 : }
2193 :
2194 : std::vector<NodeId>
2195 1797 : Conversation::peersToSyncWith() const
2196 : {
2197 1797 : auto s = pimpl_->swarmManager_->getConnectedNodes();
2198 1797 : std::lock_guard lk(pimpl_->gitSocketMtx_);
2199 12953 : for (const auto& [deviceId, _] : pimpl_->gitSocketList_)
2200 11158 : if (std::find(s.cbegin(), s.cend(), deviceId) == s.cend())
2201 1471 : s.emplace_back(deviceId);
2202 3594 : return s;
2203 1797 : }
2204 :
2205 : std::vector<MobileNodeTarget>
2206 1775 : Conversation::mobileNodesToNotify() const
2207 : {
2208 1775 : std::vector<MobileNodeTarget> targets;
2209 1775 : auto account = pimpl_->account_.lock();
2210 1774 : if (!account)
2211 0 : return targets;
2212 1774 : for (const auto& info : pimpl_->swarmManager_->getMobileNodeInfosToNotify()) {
2213 0 : const auto deviceId = info.id.toString();
2214 0 : auto uri = uriFromDevice(deviceId);
2215 : // A verified lease binds the device to its issuer, so the account URI is
2216 : // already known without touching the certificate.
2217 0 : if (uri.empty() && info.lease)
2218 0 : uri = info.lease->issuer_id.toString();
2219 0 : if (uri.empty()) {
2220 0 : auto cert = account->certStore().getCertificate(deviceId);
2221 0 : if (cert && cert->issuer)
2222 0 : uri = cert->issuer->getId().toString();
2223 0 : }
2224 0 : if (!uri.empty() && isPeerAuthorized(uri, deviceId, false))
2225 0 : targets.emplace_back(MobileNodeTarget {info.id, std::move(uri)});
2226 0 : }
2227 1775 : return targets;
2228 1775 : }
2229 :
2230 : bool
2231 1775 : Conversation::isBootstrapped() const
2232 : {
2233 1775 : return pimpl_->swarmManager_->isConnected();
2234 : }
2235 :
2236 : std::string
2237 13763 : Conversation::uriFromDevice(const std::string& deviceId) const
2238 : {
2239 13763 : return pimpl_->repository_->uriFromDevice(deviceId);
2240 : }
2241 :
2242 : std::map<std::string, std::vector<DeviceId>>
2243 25 : Conversation::memberDevices() const
2244 : {
2245 25 : return pimpl_->repository_->devices();
2246 : }
2247 :
2248 : void
2249 0 : Conversation::monitor()
2250 : {
2251 0 : pimpl_->swarmManager_->getRoutingTable().printRoutingTable();
2252 0 : }
2253 :
2254 : std::string
2255 237 : Conversation::join()
2256 : {
2257 237 : return pimpl_->repository_->join();
2258 : }
2259 :
2260 : bool
2261 9575 : Conversation::isMember(const std::string& uri, bool includeInvited) const
2262 : {
2263 9575 : auto uriCrt = uri + ".crt"sv;
2264 19146 : if (std::filesystem::is_regular_file(pimpl_->repoPath_ / MemberPath::ADMINS / uriCrt)
2265 19146 : || std::filesystem::is_regular_file(pimpl_->repoPath_ / MemberPath::MEMBERS / uriCrt)) {
2266 6847 : return true;
2267 : }
2268 2726 : if (includeInvited) {
2269 1961 : if (std::filesystem::is_regular_file(pimpl_->repoPath_ / MemberPath::INVITED / uri)) {
2270 1577 : return true;
2271 : }
2272 383 : if (mode() == ConversationMode::ONE_TO_ONE) {
2273 3 : for (const auto& member : getInitialMembers()) {
2274 2 : if (member == uri)
2275 0 : return true;
2276 1 : }
2277 : }
2278 : }
2279 1148 : return false;
2280 9572 : }
2281 :
2282 : bool
2283 6572 : Conversation::isMemberBanned(const std::string& uri) const
2284 : {
2285 6572 : return !pimpl_->memberBanType(uri).empty();
2286 : }
2287 :
2288 : bool
2289 7239 : Conversation::isDeviceBanned(const std::string& deviceId) const
2290 : {
2291 7239 : return pimpl_->isDeviceBanned(deviceId);
2292 : }
2293 :
2294 : bool
2295 6354 : Conversation::isPeerAuthorized(const std::string& uri, const std::string& deviceId, bool includeInvited) const
2296 : {
2297 6354 : return !isMemberBanned(uri) && !isDeviceBanned(deviceId) && isMember(uri, includeInvited);
2298 : }
2299 :
2300 : void
2301 174 : Conversation::createCommit(CommitMessage&& message, OnCommitCb&& onCommit, OnDoneCb&& cb)
2302 : {
2303 174 : if (!message.replyTo.empty()) {
2304 2 : if (!pimpl_->repository_->hasCommit(message.replyTo)) {
2305 1 : JAMI_ERROR("Replying to invalid commit {}", message.replyTo);
2306 1 : return;
2307 : }
2308 : }
2309 346 : dht::ThreadPool::io().run(
2310 346 : [w = weak(), message = std::move(message), onCommit = std::move(onCommit), cb = std::move(cb)] {
2311 173 : if (auto sthis = w.lock()) {
2312 173 : std::unique_lock lk(sthis->pimpl_->writeMtx_);
2313 173 : auto commit = sthis->pimpl_->repository_->commitMessage(message.toString());
2314 173 : lk.unlock();
2315 173 : if (onCommit)
2316 15 : onCommit(commit);
2317 173 : sthis->pimpl_->announce(commit, true);
2318 172 : if (cb)
2319 172 : cb(!commit.empty(), commit);
2320 347 : }
2321 172 : });
2322 : }
2323 :
2324 : bool
2325 11543 : Conversation::hasCommit(const std::string& commitId) const
2326 : {
2327 11543 : return pimpl_->repository_->hasCommit(commitId);
2328 : }
2329 :
2330 : std::optional<ConversationCommit>
2331 51 : Conversation::getCommit(const std::string& commitId) const
2332 : {
2333 51 : return pimpl_->repository_->getCommit(commitId);
2334 : }
2335 :
2336 : namespace {
2337 : /**
2338 : * Announcement commit ids retired by a removal, from a full conversation log.
2339 : *
2340 : * Which document a removal retires is read from the announcement it edits, never from
2341 : * the removal itself: the swarm only checks that an edition carries the author of the
2342 : * commit it edits, so trusting an id carried by the removal would let a member retire
2343 : * a document somebody else created.
2344 : */
2345 : std::set<std::string>
2346 58 : retiredAnnouncements(const std::vector<std::map<std::string, std::string>>& commits)
2347 : {
2348 58 : std::set<std::string> retired;
2349 282 : for (const auto& commit : commits) {
2350 224 : auto typeIt = commit.find(CommitKey::TYPE);
2351 224 : if (typeIt == commit.end() || typeIt->second != CommitType::COLLAB_DOC)
2352 177 : continue;
2353 94 : if (auto editIt = commit.find(CommitKey::EDIT); editIt != commit.end() && !editIt->second.empty())
2354 2 : retired.emplace(editIt->second);
2355 : }
2356 58 : return retired;
2357 0 : }
2358 : } // namespace
2359 :
2360 : std::vector<std::map<std::string, std::string>>
2361 58 : Conversation::collaborativeDocuments() const
2362 : {
2363 58 : if (!pimpl_->repository_)
2364 0 : return {};
2365 : // Read straight from git so documents are found even when their announcing commit
2366 : // is not (or no longer) in the loaded message window.
2367 58 : LogOptions options;
2368 58 : options.skipMerge = true;
2369 58 : auto commits = pimpl_->repository_->convCommitsToMap(pimpl_->repository_->log(options));
2370 58 : auto retired = retiredAnnouncements(commits);
2371 58 : std::vector<std::map<std::string, std::string>> result;
2372 282 : for (auto& commit : commits) {
2373 224 : auto typeIt = commit.find(CommitKey::TYPE);
2374 224 : if (typeIt == commit.end() || typeIt->second != CommitType::COLLAB_DOC)
2375 181 : continue;
2376 47 : auto uriIt = commit.find(CommitKey::URI);
2377 47 : if (uriIt == commit.end() || uriIt->second.empty())
2378 2 : continue;
2379 45 : auto idIt = commit.find("id");
2380 45 : if (idIt != commit.end() && retired.count(idIt->second) != 0)
2381 2 : continue;
2382 : // A document is addressed by its own id everywhere -- opening, renaming,
2383 : // removing -- so that is what "id" carries here; the commit's own id only
2384 : // says which timeline interaction announced it.
2385 344 : result.push_back({{"id", uriIt->second},
2386 86 : {"announcement", idIt != commit.end() ? idIt->second : ""},
2387 86 : {"displayName", commit[CommitKey::DISPLAY_NAME]},
2388 86 : {"mimeType", commit[CommitKey::MIME_TYPE]},
2389 86 : {"author", commit["author"]},
2390 86 : {"timestamp", commit["timestamp"]}});
2391 : }
2392 58 : return result;
2393 316 : }
2394 :
2395 : void
2396 2 : Conversation::loadMessages(const OnLoadMessages& cb, const LogOptions& options)
2397 : {
2398 2 : if (!cb)
2399 0 : return;
2400 2 : dht::ThreadPool::io().run([w = weak(), cb = std::move(cb), options] {
2401 2 : if (auto sthis = w.lock()) {
2402 2 : std::unique_lock lk(sthis->pimpl_->loadedHistory_.mutex);
2403 2 : auto result = sthis->pimpl_->loadMessages(options);
2404 2 : lk.unlock();
2405 2 : cb(std::move(result));
2406 4 : }
2407 2 : });
2408 : }
2409 :
2410 : void
2411 3 : Conversation::Impl::loadMissingBodyOverwrites()
2412 : {
2413 : #ifdef ENABLE_PLUGIN
2414 3 : auto& pluginChatManager = Manager::instance().getJamiPluginManager().getChatServicesManager();
2415 3 : if (!pluginChatManager.hasHandlers()) {
2416 2 : return;
2417 : }
2418 :
2419 1 : const auto& convId = repository_->id();
2420 :
2421 : // Collect all TEXT commits (originals and editions) missing a bodyOverwrite.
2422 1 : std::vector<libjami::SwarmMessage> batch;
2423 : {
2424 1 : std::lock_guard const lk(loadedHistory_.mutex);
2425 4 : for (const auto& msg : loadedHistory_.quickAccess | std::views::values) {
2426 3 : if (msg->type == CommitType::TEXT
2427 11 : && ((msg->editions.empty() && !msg->pluginData.contains("bodyOverwrite"))
2428 3 : || (!msg->editions.empty() && !msg->editions.back().contains("bodyOverwrite")))) {
2429 1 : batch.push_back(*msg);
2430 : }
2431 : }
2432 1 : }
2433 :
2434 1 : if (batch.empty()) {
2435 0 : return;
2436 : }
2437 :
2438 : // Single plugin call for all messages at once.
2439 1 : pluginChatManager.transformSwarmMessages(batch, accountId_, convId);
2440 :
2441 : // Write results back, deduplicate by signal-message id (multiple editions → same original).
2442 1 : std::map<std::string, libjami::SwarmMessage> signalMap;
2443 : {
2444 1 : std::lock_guard const lk(loadedHistory_.mutex);
2445 2 : for (const auto& transformed : batch) {
2446 2 : const auto boIt = transformed.pluginData.find("bodyOverwrite");
2447 1 : if (boIt == transformed.pluginData.end() || boIt->second.empty()) {
2448 1 : if (const auto it = loadedHistory_.quickAccess.find(transformed.id);
2449 1 : it != loadedHistory_.quickAccess.end()) {
2450 3 : it->second->pluginData["bodyOverwrite"] = "";
2451 : }
2452 1 : continue;
2453 1 : }
2454 0 : if (auto signalMsg = injectEditionOverwrites(transformed.id, boIt->second); !signalMsg.id.empty()) {
2455 0 : signalMap[signalMsg.id] = std::move(signalMsg);
2456 0 : }
2457 : }
2458 1 : }
2459 1 : for (const auto& msg : signalMap | std::views::values) {
2460 0 : emitSignal<libjami::ConversationSignal::SwarmMessageUpdated>(accountId_, convId, msg);
2461 : }
2462 : #endif
2463 1 : }
2464 :
2465 : void
2466 3 : Conversation::loadMissingBodyOverwrites() const
2467 : {
2468 3 : pimpl_->loadMissingBodyOverwrites();
2469 3 : }
2470 :
2471 : void
2472 0 : Conversation::Impl::reloadBodyOverwriteMessages()
2473 : {
2474 : #ifdef ENABLE_PLUGIN
2475 : // Clear all bodyOverwrite* entries so loadMissingBodyOverwrites() reload all body overwrites.
2476 : {
2477 0 : std::lock_guard const lk(loadedHistory_.mutex);
2478 0 : for (const auto& msg : loadedHistory_.quickAccess | std::views::values) {
2479 0 : if (msg->editions.empty()) {
2480 0 : msg->pluginData.erase("bodyOverwrite");
2481 : } else {
2482 0 : msg->pluginData.erase("bodyOverwrite");
2483 0 : for (auto& edition : msg->editions) {
2484 0 : edition.erase("bodyOverwrite");
2485 : }
2486 : }
2487 : }
2488 0 : }
2489 0 : loadMissingBodyOverwrites();
2490 : #endif
2491 0 : }
2492 :
2493 : void
2494 0 : Conversation::reloadBodyOverwriteMessages() const
2495 : {
2496 0 : pimpl_->reloadBodyOverwriteMessages();
2497 0 : }
2498 :
2499 : void
2500 0 : Conversation::Impl::updateMessageBodyOverwrite(const std::string& messageId, const std::string_view bodyOverwrite)
2501 : {
2502 : #ifdef ENABLE_PLUGIN
2503 0 : const auto& convId = repository_->id();
2504 0 : libjami::SwarmMessage signalMsg;
2505 : {
2506 0 : std::lock_guard const lk(loadedHistory_.mutex);
2507 0 : signalMsg = injectEditionOverwrites(messageId, bodyOverwrite);
2508 0 : }
2509 0 : if (!signalMsg.id.empty()) {
2510 0 : emitSignal<libjami::ConversationSignal::SwarmMessageUpdated>(accountId_, convId, signalMsg);
2511 : }
2512 : #endif
2513 0 : }
2514 :
2515 : void
2516 0 : Conversation::updateMessageBodyOverwrite(const std::string& messageId, const std::string& bodyOverwrite) const
2517 : {
2518 0 : pimpl_->updateMessageBodyOverwrite(messageId, bodyOverwrite);
2519 0 : }
2520 :
2521 : void
2522 1 : Conversation::Impl::clearBodyOverwrites()
2523 : {
2524 : #ifdef ENABLE_PLUGIN
2525 1 : const auto convId = repository_->id();
2526 1 : std::map<std::string, libjami::SwarmMessage> signalMap;
2527 : {
2528 1 : std::lock_guard const lk(loadedHistory_.mutex);
2529 : // Track which message ids need a signal: their own overwrite was cleared,
2530 : // or one of their edition commits' overwrite was cleared.
2531 4 : for (const auto& [id, msg] : loadedHistory_.quickAccess) {
2532 3 : if (msg->editions.empty()) {
2533 6 : msg->pluginData.erase("bodyOverwrite");
2534 9 : if (!msg->body.contains(CommitKey::EDIT)) {
2535 3 : signalMap[id] = *msg;
2536 : }
2537 : } else {
2538 0 : msg->pluginData.erase("bodyOverwrite");
2539 0 : for (auto& edition : msg->editions) {
2540 0 : edition.erase("bodyOverwrite");
2541 : }
2542 0 : signalMap[id] = *msg;
2543 : }
2544 : }
2545 1 : }
2546 4 : for (const auto& msg : signalMap | std::views::values) {
2547 3 : emitSignal<libjami::ConversationSignal::SwarmMessageUpdated>(accountId_, convId, msg);
2548 : }
2549 : #endif
2550 1 : }
2551 :
2552 : void
2553 1 : Conversation::clearBodyOverwrites() const
2554 : {
2555 1 : pimpl_->clearBodyOverwrites();
2556 1 : }
2557 :
2558 : void
2559 0 : Conversation::clearCache()
2560 : {
2561 0 : std::lock_guard lk(pimpl_->loadedHistory_.mutex);
2562 0 : pimpl_->loadedHistory_.messageList.clear();
2563 0 : pimpl_->loadedHistory_.quickAccess.clear();
2564 0 : pimpl_->loadedHistory_.pendingEditions.clear();
2565 0 : pimpl_->loadedHistory_.pendingReactions.clear();
2566 : {
2567 0 : std::lock_guard lk(pimpl_->messageStatusMtx_);
2568 0 : pimpl_->memberToStatus.clear();
2569 0 : }
2570 0 : }
2571 :
2572 : std::string
2573 2178 : Conversation::lastCommitId() const
2574 : {
2575 : {
2576 2178 : std::lock_guard lk(pimpl_->loadedHistory_.mutex);
2577 2179 : if (!pimpl_->loadedHistory_.messageList.empty())
2578 1514 : return (*pimpl_->loadedHistory_.messageList.begin())->id;
2579 2179 : }
2580 665 : LogOptions options;
2581 665 : options.nbOfCommits = 1;
2582 665 : options.skipMerge = true;
2583 665 : History optHistory;
2584 665 : std::scoped_lock lock(pimpl_->writeMtx_, optHistory.mutex);
2585 665 : auto res = pimpl_->loadMessages(options, &optHistory);
2586 665 : if (res.empty())
2587 2 : return {};
2588 663 : return (*optHistory.messageList.begin())->id;
2589 665 : }
2590 :
2591 : bool
2592 1817 : Conversation::pull(const std::string& deviceId, OnPullCb&& cb, std::string commitId)
2593 : {
2594 1817 : std::lock_guard lk(pimpl_->pullcbsMtx_);
2595 5454 : auto [it, notInProgress] = pimpl_->fetchingRemotes_.emplace(deviceId,
2596 3633 : std::deque<std::pair<std::string, OnPullCb>>());
2597 1818 : auto& pullcbs = it->second;
2598 0 : auto itPull = std::find_if(pullcbs.begin(), pullcbs.end(), [&](const auto& elem) {
2599 0 : return std::get<0>(elem) == commitId;
2600 3636 : });
2601 1818 : if (itPull != pullcbs.end()) {
2602 0 : JAMI_DEBUG("{} Ignoring request to pull from {:s} with commit {:s}: pull already in progress",
2603 : pimpl_->toString(),
2604 : deviceId,
2605 : commitId);
2606 0 : cb(false);
2607 0 : return false;
2608 : }
2609 1818 : JAMI_DEBUG("{} [device {}] Pulling '{:s}'", pimpl_->toString(), deviceId, commitId);
2610 1818 : pullcbs.emplace_back(std::move(commitId), std::move(cb));
2611 1818 : if (notInProgress)
2612 1811 : dht::ThreadPool::io().run([w = weak(), deviceId] {
2613 1811 : if (auto sthis_ = w.lock())
2614 1811 : sthis_->pimpl_->pull(deviceId);
2615 1810 : });
2616 1818 : return true;
2617 1818 : }
2618 :
2619 : void
2620 1811 : Conversation::Impl::pull(const std::string& deviceId)
2621 : {
2622 1811 : auto& repo = repository_;
2623 :
2624 1811 : std::string commitId;
2625 1811 : OnPullCb cb;
2626 : while (true) {
2627 : {
2628 3627 : std::lock_guard lk(pullcbsMtx_);
2629 3628 : auto it = fetchingRemotes_.find(deviceId);
2630 3628 : if (it == fetchingRemotes_.end()) {
2631 0 : JAMI_ERROR("Could not find device {:s} in fetchingRemotes", deviceId);
2632 0 : break;
2633 : }
2634 3628 : auto& pullcbs = it->second;
2635 3629 : if (pullcbs.empty()) {
2636 1811 : fetchingRemotes_.erase(it);
2637 1810 : break;
2638 : }
2639 1818 : auto& elem = pullcbs.front();
2640 1818 : commitId = std::move(std::get<0>(elem));
2641 1818 : cb = std::move(std::get<1>(elem));
2642 1818 : pullcbs.pop_front();
2643 3628 : }
2644 : // If recently fetched, the commit can already be there, so no need to do complex operations
2645 1818 : if (commitId != "" && repo->hasCommit(commitId)) {
2646 15 : cb(true);
2647 56 : continue;
2648 : }
2649 : // Pull from remote
2650 1803 : auto fetched = repo->fetch(deviceId);
2651 1803 : if (!fetched) {
2652 41 : cb(false);
2653 41 : continue;
2654 : }
2655 :
2656 1762 : auto oldHead = repo->getHead();
2657 :
2658 1762 : std::unique_lock lk(writeMtx_);
2659 : auto commits = repo->mergeHistory(deviceId,
2660 1767 : [this](const std::string& peerUri) { this->disconnectFromPeer(peerUri); });
2661 1762 : if (!commits.empty()) {
2662 983 : announce(commits);
2663 : }
2664 1762 : lk.unlock();
2665 :
2666 1762 : bool commitFound = false;
2667 1762 : if (commitId != "") {
2668 : // If `commitId` is non-empty, then we were attempting to pull a specific commit.
2669 : // We need to check if we actually got it; the fact that the fetch above was
2670 : // successful doesn't guarantee that we did.
2671 1296 : for (const auto& commit : commits) {
2672 1970 : if (commit.at("id") == commitId) {
2673 974 : commitFound = true;
2674 974 : break;
2675 : }
2676 : }
2677 : } else {
2678 477 : commitFound = true;
2679 : }
2680 1761 : if (!commitFound)
2681 311 : JAMI_WARNING("Successfully fetched from device {} but didn't receive expected commit {}",
2682 : deviceId,
2683 : commitId);
2684 : // WARNING: If its argument is `true`, this callback will attempt to send a message notification
2685 : // for commit `commitId` to other members of the swarm. It's important that we only
2686 : // send these notifications if we actually have the commit. Otherwise, we can end up
2687 : // in a situation where the members of the swarm keep sending notifications to each
2688 : // other for a commit that none of them have (note that we are unable to rule this out, as
2689 : // nothing prevents a malicious user from intentionally sending a notification with
2690 : // a fake commit ID).
2691 1761 : if (cb)
2692 1762 : cb(commitFound);
2693 :
2694 : // Announce if profile changed
2695 1761 : if (!commits.empty()) {
2696 1966 : auto diffStats = repo->diffStats("HEAD", oldHead);
2697 982 : auto changedFiles = repo->changedFiles(diffStats);
2698 983 : if (find(changedFiles.begin(), changedFiles.end(), "profile.vcf") != changedFiles.end()) {
2699 6 : emitSignal<libjami::ConversationSignal::ConversationProfileUpdated>(accountId_,
2700 6 : repo->id(),
2701 12 : repo->infos());
2702 : }
2703 983 : }
2704 3576 : }
2705 1809 : }
2706 :
2707 : void
2708 1817 : Conversation::sync(const std::string& member, const std::string& deviceId, OnPullCb&& cb, std::string commitId)
2709 : {
2710 1817 : pull(deviceId, std::move(cb), commitId);
2711 1817 : dht::ThreadPool::io().run([member, deviceId, w = weak_from_this()] {
2712 1817 : auto sthis = w.lock();
2713 1817 : if (!sthis)
2714 0 : return;
2715 : // For waiting request, downloadFile
2716 1818 : for (const auto& wr : sthis->dataTransfer()->waitingRequests()) {
2717 1 : sthis->downloadFile(wr.interactionId, wr.fileId, wr.path, member, deviceId);
2718 1816 : }
2719 1817 : });
2720 1817 : }
2721 :
2722 : std::map<std::string, std::string>
2723 316 : Conversation::generateInvitation(TimePoint sent) const
2724 : {
2725 : // Invite the new member to the conversation
2726 316 : Json::Value root;
2727 316 : auto& metadata = root[ConversationMapKeys::METADATAS];
2728 638 : for (const auto& [k, v] : infos()) {
2729 322 : if (v.size() >= 64000) {
2730 0 : JAMI_WARNING("Cutting invite because the SIP message will be too long");
2731 0 : continue;
2732 : }
2733 322 : metadata[k] = v;
2734 316 : }
2735 316 : root[ConversationMapKeys::CONVERSATIONID] = id();
2736 316 : root[ConversationMapKeys::RECEIVED] = Json::Int64(toSecondsSinceEpoch(sent));
2737 316 : root[ConversationMapKeys::RECEIVED_MS] = Json::Int64(toMillisecondsSinceEpoch(sent));
2738 1264 : return {{"application/invite+json", json::toString(root)}};
2739 632 : }
2740 :
2741 : std::string
2742 13 : Conversation::leave()
2743 : {
2744 13 : setRemovingFlag();
2745 13 : std::lock_guard lk(pimpl_->writeMtx_);
2746 26 : return pimpl_->repository_->leave();
2747 13 : }
2748 :
2749 : void
2750 17 : Conversation::setRemovingFlag()
2751 : {
2752 17 : pimpl_->isRemoving_ = true;
2753 17 : }
2754 :
2755 : bool
2756 4815 : Conversation::isRemoving()
2757 : {
2758 4815 : return pimpl_->isRemoving_;
2759 : }
2760 :
2761 : void
2762 35 : Conversation::erase()
2763 : {
2764 35 : if (pimpl_->conversationDataPath_ != "")
2765 35 : dhtnet::fileutils::removeAll(pimpl_->conversationDataPath_, true);
2766 35 : if (!pimpl_->repository_)
2767 0 : return;
2768 35 : std::lock_guard lk(pimpl_->writeMtx_);
2769 35 : pimpl_->repository_->erase();
2770 35 : }
2771 :
2772 : ConversationMode
2773 2891 : Conversation::mode() const
2774 : {
2775 2891 : return pimpl_->repository_->mode();
2776 : }
2777 :
2778 : std::string
2779 54 : Conversation::parentConversationId() const
2780 : {
2781 54 : return pimpl_->repository_->parentConversationId();
2782 : }
2783 :
2784 : std::string
2785 0 : Conversation::documentMimeType() const
2786 : {
2787 0 : return pimpl_->repository_->documentMimeType();
2788 : }
2789 :
2790 : namespace {
2791 : // The base64 update lines of every checkpoint commit in a document log,
2792 : // oldest first, i.e. in the order the updates must be replayed.
2793 : std::vector<std::string>
2794 36 : collectUpdates(const std::vector<ConversationCommit>& commits)
2795 : {
2796 36 : std::vector<std::string> updates;
2797 202 : for (auto it = commits.rbegin(); it != commits.rend(); ++it) {
2798 166 : if (it->commitMsg.type != CommitType::CHECKPOINT)
2799 154 : continue;
2800 23 : for (const auto& line : split_string(it->commitMsg.body, '\n'))
2801 11 : if (!line.empty())
2802 23 : updates.emplace_back(line);
2803 : }
2804 36 : return updates;
2805 0 : }
2806 : } // namespace
2807 :
2808 : std::vector<std::string>
2809 36 : Conversation::documentUpdates() const
2810 : {
2811 36 : LogOptions options;
2812 36 : options.skipMerge = true;
2813 72 : return collectUpdates(pimpl_->repository_->log(options));
2814 36 : }
2815 :
2816 : std::optional<std::vector<std::string>>
2817 0 : Conversation::documentUpdatesAt(const std::string& commitId) const
2818 : {
2819 0 : if (!getCommit(commitId))
2820 0 : return std::nullopt;
2821 0 : LogOptions options;
2822 0 : options.from = commitId;
2823 0 : options.skipMerge = true;
2824 0 : return collectUpdates(pimpl_->repository_->log(options));
2825 0 : }
2826 :
2827 : std::vector<std::map<std::string, std::string>>
2828 61 : Conversation::documentHistory(size_t max) const
2829 : {
2830 61 : LogOptions options;
2831 61 : options.skipMerge = true;
2832 61 : auto commits = pimpl_->repository_->log(options);
2833 61 : std::vector<std::map<std::string, std::string>> result;
2834 236 : for (const auto& commit : commits) {
2835 175 : if (commit.commitMsg.type != CommitType::CHECKPOINT)
2836 157 : continue;
2837 18 : size_t deltas = 0;
2838 36 : for (const auto& line : split_string(commit.commitMsg.body, '\n'))
2839 18 : if (!line.empty())
2840 36 : ++deltas;
2841 126 : result.emplace_back(std::map<std::string, std::string> {
2842 18 : {"id", commit.id},
2843 18 : {"author", commit.authorId},
2844 18 : {"device", commit.author.email},
2845 0 : {"timestamp", std::to_string(commit.timestamp)},
2846 18 : {"deltas", std::to_string(deltas)},
2847 108 : });
2848 18 : if (max != 0 && result.size() >= max)
2849 0 : break;
2850 : }
2851 122 : return result;
2852 79 : }
2853 :
2854 : std::pair<std::string, std::string>
2855 1 : Conversation::addDocumentAttachment(const std::vector<uint8_t>& data)
2856 : {
2857 1 : std::unique_lock lk(pimpl_->writeMtx_);
2858 1 : auto headBefore = pimpl_->repository_->getHead();
2859 1 : auto attachmentId = pimpl_->repository_->addAttachment(data);
2860 1 : if (attachmentId.empty())
2861 0 : return {};
2862 1 : auto head = pimpl_->repository_->getHead();
2863 1 : if (head == headBefore)
2864 0 : return {attachmentId, {}}; // Same content already attached, nothing new to announce
2865 1 : pimpl_->announce(head, true);
2866 1 : return {attachmentId, head};
2867 1 : }
2868 :
2869 : std::vector<uint8_t>
2870 3 : Conversation::documentAttachment(const std::string& attachmentId) const
2871 : {
2872 3 : return pimpl_->repository_->attachment(attachmentId);
2873 : }
2874 :
2875 : std::vector<std::string>
2876 36 : Conversation::documentAttachmentIds() const
2877 : {
2878 36 : return pimpl_->repository_->attachmentIds();
2879 : }
2880 :
2881 : std::vector<std::string>
2882 32 : Conversation::getInitialMembers() const
2883 : {
2884 32 : return pimpl_->repository_->getInitialMembers();
2885 : }
2886 :
2887 : bool
2888 0 : Conversation::isInitialMember(const std::string& uri) const
2889 : {
2890 0 : auto members = getInitialMembers();
2891 0 : return std::find(members.begin(), members.end(), uri) != members.end();
2892 0 : }
2893 :
2894 : void
2895 27 : Conversation::updateInfos(const std::map<std::string, std::string>& map, const OnDoneCb& cb)
2896 : {
2897 27 : dht::ThreadPool::io().run([w = weak(), map = std::move(map), cb = std::move(cb)] {
2898 27 : if (auto sthis = w.lock()) {
2899 27 : auto& repo = sthis->pimpl_->repository_;
2900 27 : std::unique_lock lk(sthis->pimpl_->writeMtx_);
2901 27 : auto commit = repo->updateInfos(map);
2902 27 : sthis->pimpl_->announce(commit, true);
2903 27 : lk.unlock();
2904 27 : if (cb)
2905 27 : cb(!commit.empty(), commit);
2906 27 : if (repo->mode() == ConversationMode::DOCUMENT)
2907 18 : return; // A document is not a conversation for the client; a rename
2908 : // is reported through CollaborativeDocumentRenamed instead
2909 9 : emitSignal<libjami::ConversationSignal::ConversationProfileUpdated>(sthis->pimpl_->accountId_,
2910 9 : repo->id(),
2911 18 : repo->infos());
2912 72 : }
2913 : });
2914 27 : }
2915 :
2916 : std::map<std::string, std::string>
2917 401 : Conversation::infos() const
2918 : {
2919 401 : return pimpl_->repository_->infos();
2920 : }
2921 :
2922 : void
2923 7 : Conversation::updatePreferences(const std::map<std::string, std::string>& map)
2924 : {
2925 7 : const auto& filePath = pimpl_->preferencesPath_;
2926 7 : auto prefs = map;
2927 7 : auto itLast = prefs.find(LAST_MODIFIED);
2928 7 : if (itLast != prefs.end()) {
2929 2 : std::error_code ec;
2930 2 : if (std::filesystem::is_regular_file(filePath, ec)) {
2931 0 : auto lastModified = fileutils::lastWriteTimeInSeconds(filePath);
2932 : try {
2933 0 : if (lastModified >= to_int<uint64_t>(itLast->second))
2934 0 : return;
2935 0 : } catch (...) {
2936 0 : return;
2937 0 : }
2938 : }
2939 2 : prefs.erase(itLast);
2940 : }
2941 :
2942 7 : std::ofstream file(filePath, std::ios::trunc | std::ios::binary);
2943 7 : msgpack::pack(file, prefs);
2944 7 : emitSignal<libjami::ConversationSignal::ConversationPreferencesUpdated>(pimpl_->accountId_, id(), std::move(prefs));
2945 7 : }
2946 :
2947 : std::map<std::string, std::string>
2948 94 : Conversation::preferences(bool includeLastModified) const
2949 : {
2950 : try {
2951 94 : std::map<std::string, std::string> preferences;
2952 94 : const auto& filePath = pimpl_->preferencesPath_;
2953 178 : auto file = fileutils::loadFile(filePath);
2954 10 : msgpack::object_handle oh = msgpack::unpack((const char*) file.data(), file.size());
2955 10 : oh.get().convert(preferences);
2956 10 : if (includeLastModified)
2957 21 : preferences[LAST_MODIFIED] = std::to_string(fileutils::lastWriteTimeInSeconds(filePath));
2958 10 : return preferences;
2959 178 : } catch (const std::exception& e) {
2960 84 : }
2961 84 : return {};
2962 : }
2963 :
2964 : std::vector<uint8_t>
2965 0 : Conversation::vCard() const
2966 : {
2967 : try {
2968 0 : return fileutils::loadFile(pimpl_->repoPath_ / "profile.vcf");
2969 0 : } catch (...) {
2970 0 : }
2971 0 : return {};
2972 : }
2973 :
2974 : std::shared_ptr<TransferManager>
2975 1966 : Conversation::dataTransfer() const
2976 : {
2977 1966 : return pimpl_->transferManager_;
2978 : }
2979 :
2980 : bool
2981 19 : Conversation::onFileChannelRequest(const std::string& member,
2982 : const std::string& fileId,
2983 : std::filesystem::path& path,
2984 : std::string& sha3sum) const
2985 : {
2986 19 : if (!isMember(member))
2987 0 : return false;
2988 :
2989 19 : if (!isValidFileId(fileId)) {
2990 2 : JAMI_WARNING("[Account {:s}] {} requested file with invalid id {}", pimpl_->accountId_, member, fileId);
2991 2 : return false;
2992 : }
2993 17 : auto sep = fileId.find('_');
2994 17 : auto interactionId = fileId.substr(0, sep);
2995 17 : auto commit = getCommit(interactionId);
2996 34 : if (commit == std::nullopt || commit->commitMsg.tid.empty() || commit->commitMsg.sha3sum.empty()
2997 34 : || commit->commitMsg.type != CommitType::DATA_TRANSFER) {
2998 0 : JAMI_WARNING("[Account {:s}] {} requested invalid file transfer commit {}",
2999 : pimpl_->accountId_,
3000 : member,
3001 : interactionId);
3002 0 : return false;
3003 : }
3004 : // The commit is the only source of truth for the file name
3005 17 : if (fileId != getFileId(interactionId, commit->commitMsg.tid, commit->commitMsg.displayName)) {
3006 2 : JAMI_WARNING("[Account {:s}] {} requested file {} not matching commit {}",
3007 : pimpl_->accountId_,
3008 : member,
3009 : fileId,
3010 : interactionId);
3011 2 : return false;
3012 : }
3013 :
3014 15 : path = dataTransfer()->path(fileId);
3015 15 : sha3sum = commit->commitMsg.sha3sum;
3016 :
3017 15 : return true;
3018 17 : }
3019 :
3020 : bool
3021 24 : Conversation::downloadFile(const std::string& interactionId,
3022 : const std::string& fileId,
3023 : const std::string& path,
3024 : const std::string&,
3025 : const std::string& deviceId)
3026 : {
3027 24 : auto commit = getCommit(interactionId);
3028 24 : if (commit == std::nullopt || commit->commitMsg.type != CommitType::DATA_TRANSFER) {
3029 0 : JAMI_ERROR("Commit doesn't exists or is not a file transfer {} (Conversation: {}) ", interactionId, id());
3030 0 : return false;
3031 : }
3032 24 : auto tid = commit->commitMsg.tid;
3033 24 : auto sha3sum = commit->commitMsg.sha3sum;
3034 24 : auto totalSize = commit->commitMsg.totalSize;
3035 :
3036 24 : if (tid.empty() || sha3sum.empty() || totalSize < 0) {
3037 0 : JAMI_ERROR("Invalid file transfer commit (missing tid, size or sha3)");
3038 0 : return false;
3039 : }
3040 24 : if (!isValidFileId(fileId) || fileId != getFileId(interactionId, tid, commit->commitMsg.displayName)) {
3041 7 : JAMI_ERROR("File id {} does not match file transfer commit {}", fileId, interactionId);
3042 7 : return false;
3043 : }
3044 17 : if (!path.empty() && std::filesystem::path(path).is_relative()) {
3045 1 : JAMI_ERROR("Refusing relative file transfer destination {}", path);
3046 1 : return false;
3047 : }
3048 :
3049 : // Be sure to not lock conversation
3050 16 : dht::ThreadPool::io().run([w = weak(), deviceId, fileId, interactionId, sha3sum, path, totalSize] {
3051 16 : auto shared = w.lock();
3052 16 : if (!shared)
3053 0 : return;
3054 16 : auto transferManager = shared->dataTransfer();
3055 16 : const auto emitTransferEvent = [accountId = shared->pimpl_->accountId_,
3056 : conversationId = shared->id(),
3057 16 : interactionId,
3058 16 : fileId](libjami::DataTransferEventCode code) {
3059 2 : emitSignal<libjami::DataTransferSignal::DataTransferEvent>(accountId,
3060 2 : conversationId,
3061 2 : interactionId,
3062 2 : fileId,
3063 : uint32_t(code));
3064 18 : };
3065 16 : switch (transferManager->waitForTransfer(fileId, interactionId, sha3sum, path, totalSize)) {
3066 2 : case TransferManager::WaitResult::complete:
3067 2 : JAMI_LOG("Ignoring request to download available file: {}", fileId);
3068 2 : emitTransferEvent(libjami::DataTransferEventCode::finished);
3069 2 : return;
3070 0 : case TransferManager::WaitResult::conflict:
3071 0 : emitTransferEvent(libjami::DataTransferEventCode::invalid_pathname);
3072 0 : return;
3073 14 : case TransferManager::WaitResult::waiting:
3074 14 : break;
3075 : }
3076 14 : auto acc = shared->pimpl_->account_.lock();
3077 14 : if (!acc)
3078 0 : return;
3079 : // Resume from the partial file left by a previous attempt, if any
3080 14 : auto destination = path.empty() ? transferManager->path(fileId) : std::filesystem::path(path);
3081 14 : std::error_code ec;
3082 14 : auto start = std::filesystem::file_size(transferManager->temporaryPath(fileId, destination), ec);
3083 14 : if (ec || start == static_cast<decltype(start)>(-1))
3084 12 : start = 0;
3085 14 : size_t end = 0;
3086 14 : acc->askForFileChannel(shared->id(), deviceId, interactionId, fileId, start, end);
3087 20 : });
3088 16 : return true;
3089 24 : }
3090 :
3091 : void
3092 1985 : Conversation::hasFetched(const std::string& deviceId, const std::string& commitId)
3093 : {
3094 1985 : dht::ThreadPool::io().run([w = weak(), deviceId, commitId]() {
3095 1986 : auto sthis = w.lock();
3096 1986 : if (!sthis)
3097 0 : return;
3098 : // Update fetched for Uri
3099 1986 : auto uri = sthis->uriFromDevice(deviceId);
3100 1986 : if (uri.empty() || uri == sthis->pimpl_->userId_)
3101 45 : return;
3102 : // When a user fetches a commit, the message is sent for this person
3103 1941 : sthis->pimpl_->updateStatus(uri,
3104 : libjami::Account::MessageStates::SENT,
3105 1941 : commitId,
3106 3882 : std::to_string(std::time(nullptr)),
3107 : true);
3108 2031 : });
3109 1986 : }
3110 :
3111 : void
3112 1994 : Conversation::Impl::updateStatus(const std::string& uri,
3113 : libjami::Account::MessageStates st,
3114 : const std::string& commitId,
3115 : const std::string& ts,
3116 : bool emit)
3117 : {
3118 : // This method can be called if peer send us a status or if another device sync. Emit will be true if a peer
3119 : // send us a status and will emit to other connected devices.
3120 1994 : LogOptions options;
3121 1993 : std::map<std::string, std::map<std::string, std::string>> newStatus;
3122 : {
3123 : // Update internal structures.
3124 1994 : std::lock_guard lk(messageStatusMtx_);
3125 1994 : auto& status = messagesStatus_[uri];
3126 1994 : auto& oldStatus = status[st == libjami::Account::MessageStates::SENT ? "fetched" : "read"];
3127 1994 : if (oldStatus == commitId)
3128 207 : return; // Nothing to do
3129 1787 : options.to = oldStatus;
3130 1787 : options.from = commitId;
3131 1787 : oldStatus = commitId;
3132 1787 : status[st == libjami::Account::MessageStates::SENT ? "fetched_ts" : "read_ts"] = ts;
3133 1787 : saveStatus();
3134 1787 : if (emit)
3135 1750 : newStatus[uri].insert(status.begin(), status.end());
3136 1994 : }
3137 1787 : if (emit && messageStatusCb_) {
3138 1730 : messageStatusCb_(newStatus);
3139 : }
3140 : // Update messages status for all commit between the old and new one
3141 1787 : options.logIfNotFound = false;
3142 1787 : options.fastLog = true;
3143 1787 : History optHistory;
3144 1787 : std::unique_lock lk(optHistory.mutex); // Avoid to announce messages while updating status.
3145 1787 : auto res = loadMessages(options, &optHistory);
3146 1785 : std::unique_lock mlk(messageStatusMtx_);
3147 1787 : std::vector<std::pair<std::string, int32_t>> statusToUpdate;
3148 1787 : if (res.size() == 0) {
3149 : // In this case, commit is not received yet, so we cache it
3150 21 : futureStatus[commitId][uri] = static_cast<int32_t>(st);
3151 : }
3152 18112 : for (const auto& [cid, _] : optHistory.quickAccess) {
3153 16325 : auto message = loadedHistory_.quickAccess.find(cid);
3154 16325 : if (message != loadedHistory_.quickAccess.end()) {
3155 : // Update message and emit to client,
3156 4879 : if (static_cast<int32_t>(st) > message->second->status[uri]) {
3157 4873 : message->second->status[uri] = static_cast<int32_t>(st);
3158 4872 : statusToUpdate.emplace_back(cid, static_cast<int32_t>(st));
3159 : }
3160 : } else {
3161 : // In this case, commit is not loaded by client, so we cache it
3162 : // No need to emit to client, they will get a correct status on load.
3163 11447 : futureStatus[cid][uri] = static_cast<int32_t>(st);
3164 : }
3165 : }
3166 1787 : mlk.unlock();
3167 1787 : lk.unlock();
3168 6658 : for (const auto& [cid, status] : statusToUpdate)
3169 9741 : emitSignal<libjami::ConfigurationSignal::AccountMessageStatusChanged>(accountId_,
3170 4872 : repository_->id(),
3171 : uri,
3172 : cid,
3173 : static_cast<int>(status));
3174 2199 : }
3175 :
3176 : bool
3177 18 : Conversation::setMessageDisplayed(const std::string& uri, const std::string& interactionId)
3178 : {
3179 18 : std::lock_guard lk(pimpl_->messageStatusMtx_);
3180 54 : if (pimpl_->messagesStatus_[uri]["read"] == interactionId)
3181 2 : return false; // Nothing to do
3182 16 : dht::ThreadPool::io().run([w = weak(), uri, interactionId]() {
3183 16 : auto sthis = w.lock();
3184 16 : if (!sthis)
3185 0 : return;
3186 16 : sthis->pimpl_->updateStatus(uri,
3187 : libjami::Account::MessageStates::DISPLAYED,
3188 16 : interactionId,
3189 32 : std::to_string(std::time(nullptr)),
3190 : true);
3191 16 : });
3192 16 : return true;
3193 18 : }
3194 :
3195 : std::map<std::string, std::map<std::string, std::string>>
3196 76 : Conversation::messageStatus() const
3197 : {
3198 76 : std::lock_guard lk(pimpl_->messageStatusMtx_);
3199 152 : return pimpl_->messagesStatus_;
3200 76 : }
3201 :
3202 : void
3203 51 : Conversation::updateMessageStatus(const std::map<std::string, std::map<std::string, std::string>>& messageStatus)
3204 : {
3205 51 : std::unique_lock lk(pimpl_->messageStatusMtx_);
3206 51 : std::vector<std::tuple<libjami::Account::MessageStates, std::string, std::string, std::string>> stVec;
3207 118 : for (const auto& [uri, status] : messageStatus) {
3208 67 : auto& oldMs = pimpl_->messagesStatus_[uri];
3209 401 : if (status.find("fetched_ts") != status.end() && status.at("fetched") != oldMs["fetched"]) {
3210 155 : if (oldMs["fetched_ts"].empty() || std::stol(oldMs["fetched_ts"]) <= std::stol(status.at("fetched_ts"))) {
3211 0 : stVec.emplace_back(libjami::Account::MessageStates::SENT,
3212 : uri,
3213 33 : status.at("fetched"),
3214 99 : status.at("fetched_ts"));
3215 : }
3216 : }
3217 229 : if (status.find("read_ts") != status.end() && status.at("read") != oldMs["read"]) {
3218 12 : if (oldMs["read_ts"].empty() || std::stol(oldMs["read_ts"]) <= std::stol(status.at("read_ts"))) {
3219 0 : stVec.emplace_back(libjami::Account::MessageStates::DISPLAYED,
3220 : uri,
3221 4 : status.at("read"),
3222 12 : status.at("read_ts"));
3223 : }
3224 : }
3225 : }
3226 51 : lk.unlock();
3227 :
3228 88 : for (const auto& [status, uri, commitId, ts] : stVec) {
3229 37 : pimpl_->updateStatus(uri, status, commitId, ts);
3230 : }
3231 51 : }
3232 :
3233 : void
3234 476 : Conversation::onMessageStatusChanged(
3235 : const std::function<void(const std::map<std::string, std::map<std::string, std::string>>&)>& cb)
3236 : {
3237 476 : std::unique_lock lk(pimpl_->messageStatusMtx_);
3238 476 : pimpl_->messageStatusCb_ = cb;
3239 476 : }
3240 :
3241 : #ifdef LIBJAMI_TEST
3242 : void
3243 635 : Conversation::onBootstrapStatus(const std::function<void(std::string, BootstrapStatus)>& cb)
3244 : {
3245 635 : std::lock_guard lock(pimpl_->bootstrapMtx_);
3246 635 : pimpl_->bootstrapCbTest_ = cb;
3247 635 : }
3248 :
3249 : std::vector<libjami::SwarmMessage>
3250 0 : Conversation::loadMessagesSync(const LogOptions& options)
3251 : {
3252 0 : std::lock_guard lk(pimpl_->loadedHistory_.mutex);
3253 0 : auto result = pimpl_->loadMessages(options);
3254 0 : return result;
3255 0 : }
3256 :
3257 : void
3258 0 : Conversation::announce(const std::vector<std::map<std::string, std::string>>& commits, bool commitFromSelf)
3259 : {
3260 0 : pimpl_->announce(commits, commitFromSelf);
3261 0 : }
3262 :
3263 : void
3264 0 : Conversation::announce(const std::string& commitId, bool commitFromSelf)
3265 : {
3266 0 : pimpl_->announce(commitId, commitFromSelf);
3267 0 : }
3268 : #endif
3269 :
3270 : void
3271 634 : Conversation::bootstrap(std::function<void()> onBootstrapped, const std::vector<DeviceId>& knownDevices)
3272 : {
3273 634 : std::lock_guard lock(pimpl_->bootstrapMtx_);
3274 634 : if (!pimpl_ || !pimpl_->repository_ || !pimpl_->swarmManager_)
3275 0 : return;
3276 : // Bootstrap the DRT from currently known devices. Since the per-device
3277 : // presence monitoring (monitorConnection/startTracking below), callers do
3278 : // not pass any device list here: candidates are injected as members get
3279 : // online, through addKnownDevices() with devices reported by the
3280 : // PresenceManager (i.e. announced on the DHT), and rotated on connection
3281 : // failure. The knownDevices parameter remains for callers/tests that
3282 : // already hold a list of live devices.
3283 : // If a connection succeeds, onConnectionChanged will be called with ok=true
3284 634 : pimpl_->bootstrapCb_ = std::move(onBootstrapped);
3285 634 : std::vector<DeviceId> devices = knownDevices;
3286 634 : JAMI_DEBUG("{} Bootstrap with {} device(s)", pimpl_->toString(), devices.size());
3287 :
3288 634 : if (!devices.empty()) {
3289 0 : pimpl_->swarmManager_->setKnownNodes(devices);
3290 : }
3291 :
3292 634 : pimpl_->monitorConnection(weak_from_this());
3293 :
3294 : // If is shutdown, the conversation was re-added, causing no new nodes to be connected, but just a classic
3295 : // connectivity change
3296 634 : if (pimpl_->swarmManager_->isShutdown()) {
3297 20 : pimpl_->swarmManager_->restart();
3298 20 : pimpl_->swarmManager_->maintainBuckets();
3299 614 : } else if (!pimpl_->swarmManager_->isConnected()) {
3300 : // A swarm manager that is up but holds no connected node will never get
3301 : // one on its own: setKnownNodes() only acts on ids it has never seen
3302 : // before, so members it already knows are simply skipped.
3303 : //
3304 : // A mobile client reaches that state every time it leaves and returns
3305 : // to the foreground: setAccountActive() leaves established connections
3306 : // alone by default, so the swarm manager is not shut down, yet the
3307 : // links die with the network. Bootstrapping then finds nothing to do,
3308 : // and the conversation stays silent until the process is restarted.
3309 603 : pimpl_->swarmManager_->maintainBuckets();
3310 : }
3311 634 : }
3312 :
3313 : void
3314 1855 : Conversation::addKnownDevices(const std::vector<DeviceId>& devices, const std::string& memberUri)
3315 : {
3316 1855 : if (devices.empty())
3317 1017 : return;
3318 838 : if (!memberUri.empty()) {
3319 : // JAMI_WARNING("{} Adding {} known devices for member {}", pimpl_->toString(), devices.size(), memberUri);
3320 838 : std::lock_guard lk(pimpl_->trackedMembersMtx_);
3321 838 : auto it = pimpl_->trackedMembers_.find(memberUri);
3322 838 : if (it != pimpl_->trackedMembers_.end()) {
3323 833 : it->second.devices.insert(devices.begin(), devices.end());
3324 : }
3325 838 : } else {
3326 0 : JAMI_ERROR("{} Adding {} known devices without member URI", pimpl_->toString(), devices.size());
3327 : }
3328 838 : pimpl_->swarmManager_->setKnownNodes(devices);
3329 : }
3330 :
3331 : void
3332 518 : Conversation::connectNode(const DeviceId& deviceId)
3333 : {
3334 518 : pimpl_->swarmManager_->connectNode(deviceId);
3335 518 : }
3336 :
3337 : std::vector<std::string>
3338 18 : Conversation::commitsEndedCalls()
3339 : {
3340 18 : pimpl_->loadActiveCalls();
3341 18 : pimpl_->loadHostedCalls();
3342 18 : auto commits = pimpl_->commitsEndedCalls();
3343 18 : if (!commits.empty()) {
3344 : // Announce to client
3345 0 : dht::ThreadPool::io().run([w = weak(), commits] {
3346 0 : if (auto sthis = w.lock())
3347 0 : sthis->pimpl_->announce(commits, true);
3348 0 : });
3349 : }
3350 18 : return commits;
3351 0 : }
3352 :
3353 : void
3354 492 : Conversation::onMembersChanged(OnMembersChanged&& cb)
3355 : {
3356 492 : pimpl_->onMembersChanged_ = std::move(cb);
3357 492 : }
3358 :
3359 : void
3360 492 : Conversation::onNeedSocket(NeedSocketCb needSocket)
3361 : {
3362 984 : pimpl_->swarmManager_->needSocketCb_ = [needSocket = std::move(needSocket),
3363 : w = weak()](const std::string& deviceId, ChannelCb&& cb, bool noNewSocket) {
3364 1128 : if (auto sthis = w.lock()) {
3365 1128 : auto wrappedCb = [cb = std::move(cb), w, deviceId](const std::shared_ptr<dhtnet::ChannelSocket>& socket) {
3366 1125 : if (auto sthis = w.lock()) {
3367 1121 : if (!socket) {
3368 368 : if (auto acc = sthis->pimpl_->account_.lock()) {
3369 369 : auto cert = acc->certStore().getCertificate(deviceId);
3370 369 : if (cert && cert->issuer) {
3371 369 : sthis->pimpl_->onConnectionFailed(DeviceId(deviceId), cert->issuer->getId().toString());
3372 : } else {
3373 0 : JAMI_WARNING("{} Unable to get member URI from device ID {}",
3374 : sthis->pimpl_->toString(),
3375 : deviceId);
3376 : }
3377 369 : } else {
3378 0 : return false;
3379 369 : }
3380 : }
3381 1128 : }
3382 1128 : return cb(socket);
3383 1128 : };
3384 2254 : needSocket(sthis->id(), deviceId, std::move(wrappedCb), "application/im-gitmessage-id", noNewSocket);
3385 2256 : }
3386 1620 : };
3387 492 : }
3388 :
3389 : void
3390 986 : Conversation::addSwarmChannel(std::shared_ptr<dhtnet::ChannelSocket> channel)
3391 : {
3392 986 : auto deviceId = channel->deviceId();
3393 : // Transmit avatar if necessary
3394 : // We do this here, because at this point we know both sides are connected and in
3395 : // the same conversation
3396 : // addSwarmChannel is a bit more complex, but it should be the best moment to do this.
3397 987 : auto cert = channel->peerCertificate();
3398 987 : if (!cert || !cert->issuer)
3399 0 : return;
3400 986 : auto member = cert->issuer->getId().toString();
3401 : // The TLS handshake authenticated this certificate: pin it so that any mobile
3402 : // lease this device gossips can be verified without a lookup.
3403 987 : if (auto account = pimpl_->account_.lock())
3404 986 : account->certStore().pinCertificate(cert);
3405 987 : pimpl_->swarmManager_->addChannel(std::move(channel));
3406 987 : dht::ThreadPool::io().run([member, deviceId, a = pimpl_->account_, w = weak_from_this()] {
3407 987 : auto sthis = w.lock();
3408 987 : if (auto account = a.lock()) {
3409 987 : account->sendProfile(sthis->id(), member, deviceId.toString());
3410 986 : }
3411 987 : });
3412 987 : }
3413 :
3414 : uint32_t
3415 4 : Conversation::countInteractions(const std::string& toId, const std::string& fromId, const std::string& authorUri) const
3416 : {
3417 4 : LogOptions options;
3418 4 : options.to = toId;
3419 4 : options.from = fromId;
3420 4 : options.authorUri = authorUri;
3421 4 : options.logIfNotFound = false;
3422 4 : options.fastLog = true;
3423 4 : History history;
3424 4 : std::lock_guard lk(history.mutex);
3425 4 : auto res = pimpl_->loadMessages(options, &history);
3426 8 : return res.size();
3427 4 : }
3428 :
3429 : void
3430 4 : Conversation::search(uint32_t req, const Filter& filter, const std::shared_ptr<std::atomic_int>& flag) const
3431 : {
3432 : // Because logging a conversation can take quite some time,
3433 : // do it asynchronously
3434 4 : dht::ThreadPool::io().run([w = weak(), req, filter, flag] {
3435 4 : if (auto sthis = w.lock()) {
3436 4 : History history;
3437 4 : std::vector<std::map<std::string, std::string>> commits {};
3438 : // std::regex_constants::ECMAScript is the default flag.
3439 4 : auto re = std::regex(filter.regexSearch,
3440 4 : filter.caseSensitive ? std::regex_constants::ECMAScript : std::regex_constants::icase);
3441 12 : sthis->pimpl_->repository_->log(
3442 8 : [&](const std::string& /*id*/, const GitAuthor& author, const GitCommit& commit) {
3443 20 : if (!filter.author.empty() && filter.author != sthis->uriFromDevice(author.email)) {
3444 : // Filter author
3445 0 : return CallbackResult::Skip;
3446 : }
3447 20 : auto commitTime = git_commit_time(commit.get());
3448 20 : if (filter.before && filter.before < commitTime) {
3449 : // Only get commits before this date
3450 0 : return CallbackResult::Skip;
3451 : }
3452 20 : if (filter.after && filter.after > commitTime) {
3453 : // Only get commits before this date
3454 0 : if (git_commit_parentcount(commit.get()) <= 1)
3455 0 : return CallbackResult::Break;
3456 : else
3457 0 : return CallbackResult::Skip; // Because we are sorting it with
3458 : // GIT_SORT_TOPOLOGICAL | GIT_SORT_TIME
3459 : }
3460 :
3461 20 : return CallbackResult::Ok; // Continue
3462 : },
3463 8 : [&](ConversationCommit&& cc) {
3464 20 : if (auto optMessage = sthis->pimpl_->repository_->convCommitToMap(cc))
3465 80 : sthis->pimpl_->addToHistory(history, {optMessage.value()}, false, false);
3466 40 : },
3467 8 : [&](const std::string& id, const GitAuthor&, ConversationCommit&) {
3468 20 : if (id == filter.lastId)
3469 0 : return true;
3470 20 : return false;
3471 : },
3472 : "",
3473 : false);
3474 : // Search on generated history
3475 24 : for (auto& message : history.messageList) {
3476 20 : auto contentType = message->type;
3477 20 : auto isSearchable = contentType == CommitType::TEXT || contentType == CommitType::DATA_TRANSFER;
3478 20 : if (filter.type.empty() && !isSearchable) {
3479 : // Not searchable, at least for now
3480 8 : continue;
3481 12 : } else if (contentType == filter.type || filter.type.empty()) {
3482 12 : if (isSearchable) {
3483 : // If it's a text match the body, else the display name
3484 60 : auto body = contentType == CommitType::TEXT ? message->body.at(CommitKey::BODY)
3485 36 : : message->body.at(CommitKey::DISPLAY_NAME);
3486 12 : std::smatch body_match;
3487 12 : if (std::regex_search(body, body_match, re)) {
3488 5 : auto commit = message->body;
3489 10 : commit["id"] = message->id;
3490 10 : commit[CommitKey::TYPE] = message->type;
3491 5 : commits.emplace_back(commit);
3492 5 : }
3493 12 : } else {
3494 : // Matching type, just add it to the results
3495 0 : commits.emplace_back(message->body);
3496 : }
3497 :
3498 12 : if (filter.maxResult != 0 && commits.size() == filter.maxResult)
3499 0 : break;
3500 : }
3501 20 : }
3502 :
3503 4 : if (commits.size() > 0)
3504 9 : emitSignal<libjami::ConversationSignal::MessagesFound>(req,
3505 3 : sthis->pimpl_->accountId_,
3506 6 : sthis->id(),
3507 3 : std::move(commits));
3508 : // If we're the latest thread, inform client that the search is finished
3509 4 : if ((*flag)-- == 1 /* decrement return the old value */) {
3510 8 : emitSignal<libjami::ConversationSignal::MessagesFound>(
3511 8 : req, sthis->pimpl_->accountId_, std::string {}, std::vector<std::map<std::string, std::string>> {});
3512 : }
3513 8 : }
3514 4 : });
3515 4 : }
3516 :
3517 : void
3518 14 : Conversation::hostConference(CommitMessage&& message, OnDoneCb&& cb)
3519 : {
3520 14 : if (message.confId.empty()) {
3521 0 : JAMI_ERROR("{}Malformed commit: no confId", pimpl_->toString());
3522 0 : return;
3523 : }
3524 :
3525 14 : auto now = std::chrono::system_clock::now();
3526 14 : auto nowSecs = std::chrono::duration_cast<std::chrono::seconds>(now.time_since_epoch()).count();
3527 : {
3528 14 : std::lock_guard lk(pimpl_->activeCallsMtx_);
3529 14 : pimpl_->hostedCalls_[message.confId] = nowSecs;
3530 14 : pimpl_->saveHostedCalls();
3531 14 : }
3532 :
3533 14 : createCommit(std::move(message), {}, std::move(cb));
3534 : }
3535 :
3536 : bool
3537 20 : Conversation::isHosting(const std::string& confId) const
3538 : {
3539 20 : auto info = infos();
3540 64 : if (info["rdvDevice"] == pimpl_->deviceId_ && info["rdvHost"] == pimpl_->userId_)
3541 0 : return true; // We are the current device Host
3542 20 : std::lock_guard lk(pimpl_->activeCallsMtx_);
3543 20 : return pimpl_->hostedCalls_.find(confId) != pimpl_->hostedCalls_.end();
3544 20 : }
3545 :
3546 : void
3547 10 : Conversation::removeActiveConference(CommitMessage&& message, OnDoneCb&& cb)
3548 : {
3549 10 : if (message.confId.empty()) {
3550 0 : JAMI_ERROR("{}Malformed commit: no confId", pimpl_->toString());
3551 0 : return;
3552 : }
3553 :
3554 10 : auto erased = false;
3555 : {
3556 10 : std::lock_guard lk(pimpl_->activeCallsMtx_);
3557 10 : erased = pimpl_->hostedCalls_.erase(message.confId);
3558 10 : }
3559 10 : if (erased) {
3560 10 : pimpl_->saveHostedCalls();
3561 10 : createCommit(std::move(message), {}, std::move(cb));
3562 : } else
3563 0 : cb(false, "");
3564 : }
3565 :
3566 : std::vector<std::map<std::string, std::string>>
3567 38 : Conversation::currentCalls() const
3568 : {
3569 38 : std::lock_guard lk(pimpl_->activeCallsMtx_);
3570 76 : return pimpl_->activeCalls_;
3571 38 : }
3572 : } // namespace jami
|