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