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