RunicVTT Open Source Virtual Tabletop for TTRPG using P2P
Loading...
Searching...
No Matches
ChatManager.cpp
Go to the documentation of this file.
1#include "ChatManager.h"
2#include "NetworkManager.h"
3#include "PeerLink.h"
4#include "Serializer.h"
5#include "Logger.h"
6#include <algorithm>
7#include <cctype>
8#include <cstring>
9#include <fstream>
10#include <random>
11#include <cstdint>
12#include <unordered_map>
13#include "PathManager.h"
14
15// ====== small utils ======
17{
18 return (double)std::time(nullptr);
19}
20
21static bool ends_with_icase(const std::string& a, const char* suf)
22{
23 const size_t n = std::strlen(suf);
24 if (a.size() < n)
25 return false;
26 return std::equal(a.end() - n, a.end(), suf, suf + n, [](char c1, char c2)
27 { return (char)std::tolower((unsigned char)c1) == (char)std::tolower((unsigned char)c2); });
28}
29
31{
32 if (ends_with_icase(s, ".png") || ends_with_icase(s, ".jpg") || ends_with_icase(s, ".jpeg"))
34 if (s.rfind("http://", 0) == 0 || s.rfind("https://", 0) == 0)
37}
38
39// ====== ctor/bind ======
40ChatManager::ChatManager(std::weak_ptr<NetworkManager> nm, std::shared_ptr<IdentityManager> identity_manager) :
41 network_(std::move(nm)), identity_manager(identity_manager) {}
42void ChatManager::setNetwork(std::weak_ptr<NetworkManager> nm)
43{
44 network_ = std::move(nm);
45}
46
47// ====== context ======
48void ChatManager::setActiveGameTable(uint64_t tableId, const std::string& gameTableName)
49{
50 if (currentTableId_ != 0)
52 currentTableId_ = tableId;
53 currentTableName_ = gameTableName;
54
55 groups_.clear();
57
58 loadCurrent(); // will merge/overwrite groups_ from disk
59
61 focusInput_ = true;
62 followScroll_ = true;
63}
64
66{
67 if (groups_.find(generalGroupId_) == groups_.end())
68 {
71 g.name = "General";
72 g.ownerUniqueId.clear(); // nobody “owns” General
73 groups_.emplace(g.id, std::move(g));
74 }
75}
76
77// ====== storage ======
78std::filesystem::path ChatManager::chatFilePathFor(uint64_t tableId, const std::string& name) const
79{
80 auto gametables_folder = PathManager::getGameTablesPath();
81 auto gametable_name_folder = gametables_folder / name;
82 return gametable_name_folder / std::filesystem::path(name + "_" + std::to_string(tableId) + "_chatgroups.runic");
83}
84
85bool ChatManager::saveLog(std::vector<uint8_t>& buf) const
86{
87 Serializer::serializeString(buf, "RUNIC-CHAT-GROUPS");
88 Serializer::serializeInt(buf, 2); // <— version 2
89
90 Serializer::serializeInt(buf, (int)groups_.size());
91 for (auto& [id, g] : groups_)
92 {
94 Serializer::serializeString(buf, g.name);
95 Serializer::serializeString(buf, g.ownerUniqueId); // <— changed
96
97 Serializer::serializeInt(buf, (int)g.participants.size());
98 for (auto& p : g.participants)
99 Serializer::serializeString(buf, p); // your set contents (uniqueIds recommended)
100
101 Serializer::serializeInt(buf, (int)g.messages.size());
102 for (auto& m : g.messages)
103 {
104 Serializer::serializeInt(buf, (int)m.kind);
105 Serializer::serializeString(buf, m.senderUniqueId); // <— NEW in v2
106 Serializer::serializeString(buf, m.username);
107 Serializer::serializeString(buf, m.content);
108 Serializer::serializeUInt64(buf, (uint64_t)m.ts);
109 }
110 Serializer::serializeInt(buf, (int)g.unread);
111 }
112 return true;
113}
114bool ChatManager::loadLog(const std::vector<uint8_t>& buf)
115{
116 size_t off = 0;
117 auto magic = Serializer::deserializeString(buf, off);
118 if (magic != "RUNIC-CHAT-GROUPS")
119 return false;
120
121 int version = Serializer::deserializeInt(buf, off);
122
123 groups_.clear();
124 int count = Serializer::deserializeInt(buf, off);
125 for (int i = 0; i < count; ++i)
126 {
128 g.id = Serializer::deserializeUInt64(buf, off);
129 g.name = Serializer::deserializeString(buf, off);
130
131 if (version >= 2)
132 g.ownerUniqueId = Serializer::deserializeString(buf, off);
133 else
134 {
135 // v1 had ownerPeerId; read it and keep as ownerUniqueId (best-effort: it used to be peerId)
136 const std::string legacyOwner = Serializer::deserializeString(buf, off);
137 g.ownerUniqueId = legacyOwner; // you can map through IdentityManager if you persisted mapping
138 }
139
140 int pc = Serializer::deserializeInt(buf, off);
141 for (int k = 0; k < pc; ++k)
142 g.participants.insert(Serializer::deserializeString(buf, off));
143
144 int mc = Serializer::deserializeInt(buf, off);
145 for (int m = 0; m < mc; ++m)
146 {
149
150 if (version >= 2)
151 msg.senderUniqueId = Serializer::deserializeString(buf, off);
152 else
153 msg.senderUniqueId.clear();
154
155 msg.username = Serializer::deserializeString(buf, off);
156 msg.content = Serializer::deserializeString(buf, off);
157 msg.ts = (double)Serializer::deserializeUInt64(buf, off);
158 g.messages.push_back(std::move(msg));
159 }
160
161 g.unread = (uint32_t)Serializer::deserializeInt(buf, off);
162 groups_.emplace(g.id, std::move(g));
163 }
164
166 if (groups_.find(activeGroupId_) == groups_.end())
168 return true;
169}
170
172{
173 if (!hasCurrent())
174 return false;
175 try
176 {
177 std::vector<uint8_t> buf;
178 saveLog(buf);
180 std::ofstream os(p, std::ios::binary | std::ios::trunc);
181 os.write((const char*)buf.data(), (std::streamsize)buf.size());
182 return true;
183 }
184 catch (...)
185 {
186 return false;
187 }
188}
189
191{
192 if (!hasCurrent())
193 return false;
195 if (!std::filesystem::exists(p))
196 {
198 return false;
199 }
200
201 try
202 {
203 std::ifstream is(p, std::ios::binary);
204 is.seekg(0, std::ios::end);
205 auto sz = is.tellg();
206 is.seekg(0, std::ios::beg);
207 std::vector<uint8_t> buf((size_t)sz);
208 if (sz > 0)
209 is.read((char*)buf.data(), sz);
210 return loadLog(buf);
211 }
212 catch (...)
213 {
215 return false;
216 }
217}
218
219// ====== ids ======
220uint64_t ChatManager::makeGroupIdFromName(const std::string& name) const
221{
222 std::hash<std::string> H;
223 uint64_t id = H(name);
224 if (id == 0 || id == generalGroupId_)
225 id ^= 0x9E3779B97F4A7C15ull;
226 return id;
227}
228
229// Returns true if my UID is currently a participant of group g.
231{
232 if (!identity_manager)
233 return false;
234 const std::string meUid = identity_manager->myUniqueId();
235 return g.participants.count(meUid) > 0;
236}
237
239{
240 auto it = groups_.find(id);
241 return it == groups_.end() ? nullptr : &it->second;
242}
244{
245 using K = msg::DCType;
246
247 switch (m.kind)
248 {
249 case K::ChatGroupCreate:
250 {
251 if (!m.tableId || !m.threadId)
252 return;
253 if (*m.tableId != currentTableId_)
254 return;
255
257 g.id = *m.threadId;
258 g.name = m.name.value_or("Group");
259
260 // derive owner uniqueId (best-effort)
261 std::string ownerUid;
263 ownerUid = identity_manager->uniqueForPeer(m.fromPeerId).value_or("Player");
264 g.ownerUniqueId = ownerUid;
265
266 if (m.participants)
267 g.participants = *m.participants;
268
269 auto it = groups_.find(g.id);
270 if (it == groups_.end())
271 groups_.emplace(g.id, std::move(g));
272 else
273 {
274 it->second.name = g.name;
275 it->second.participants = std::move(g.participants);
276 // keep existing ownerUniqueId if you want
277 }
278
279 Logger::instance().log("chat", Logger::Level::Info, "RX GroupCreate id=" + std::to_string(*m.threadId) + " name=" + m.name.value_or("Group"));
280 break;
281 }
282
283 case K::ChatGroupUpdate:
284 {
285 if (!m.tableId || !m.threadId)
286 return;
287 if (*m.tableId != currentTableId_)
288 return;
289
290 auto* g = getGroup(*m.threadId);
291 if (!g)
292 {
293 ChatGroupModel stub;
294 stub.id = *m.threadId;
295 stub.name = m.name.value_or("Group");
296 if (m.participants)
297 stub.participants = *m.participants;
298
299 // best-effort owner
301 stub.ownerUniqueId = identity_manager->uniqueForPeer(m.fromPeerId).value_or("Player");
302
303 groups_.emplace(stub.id, std::move(stub));
304 }
305 else
306 {
307 if (m.name)
308 g->name = *m.name;
309 if (m.participants)
310 g->participants = *m.participants;
311 }
312 break;
313 }
314
315 case K::ChatGroupDelete:
316 {
317 if (!m.tableId || !m.threadId)
318 return;
319 if (*m.tableId != currentTableId_)
320 return;
321 if (*m.threadId == generalGroupId_)
322 return; // never delete General
323
324 if (auto* g = getGroup(*m.threadId))
325 {
326 // permission: only owner can delete
327 const std::string reqOwner =
328 identity_manager ? identity_manager->uniqueForPeer(m.fromPeerId).value_or("Player") : std::string{};
329 if (!g->ownerUniqueId.empty() && g->ownerUniqueId == reqOwner)
330 {
331 groups_.erase(*m.threadId);
332 if (activeGroupId_ == *m.threadId)
334 }
335 }
336 break;
337 }
338
339 case K::ChatMessage:
340 {
341 if (!m.tableId || !m.threadId || !m.ts || !m.name || !m.text)
342 return;
343 if (*m.tableId != currentTableId_)
344 return;
345
346 // map sender to uniqueId if possible (if ReadyMessage.userPeerId later carries it, use that)
347 std::string senderUid =
348 identity_manager ? identity_manager->uniqueForPeer(m.fromPeerId).value_or("Player") : std::string{};
349
350 // inline append to include senderUniqueId
352 msg.kind = classifyMessage(*m.text);
353 msg.senderUniqueId = senderUid;
354 msg.username = *m.name;
355 msg.content = *m.text;
356 msg.ts = (double)*m.ts;
357
358 auto* g = getGroup(*m.threadId);
359 if (!g)
360 {
361 ChatGroupModel stub;
362 stub.id = *m.threadId;
363 stub.name = "(pending?)";
364 groups_.emplace(stub.id, std::move(stub));
365 g = getGroup(*m.threadId);
366 }
367 g->messages.push_back(std::move(msg));
368
369 const bool isActive = (activeGroupId_ == *m.threadId);
370 if (!isActive || !chatWindowFocused_ || !followScroll_)
371 g->unread = std::min<uint32_t>(g->unread + 1, 999u);
372 break;
373 }
374
375 default:
376 break;
377 }
378}
379
380// ====== local append ======
381void ChatManager::pushMessageLocal(uint64_t groupId,
382 const std::string& fromPeer,
383 const std::string& username,
384 const std::string& text,
385 double ts,
386 bool incoming)
387{
389 msg.kind = classifyMessage(text);
390 msg.username = username;
391 msg.content = text;
392 msg.ts = ts;
393
394 if (incoming)
395 {
396 // map from transport peer -> stable uniqueId (best effort)
397 msg.senderUniqueId = identity_manager ? identity_manager->uniqueForPeer(fromPeer).value_or("Player") : std::string{};
398 }
399 else
400 {
401 // local user’s unique id
402 msg.senderUniqueId = identity_manager ? identity_manager->myUniqueId() : std::string{};
403 }
404
405 auto* g = getGroup(groupId);
406 if (!g)
407 {
408 ChatGroupModel stub;
409 stub.id = groupId;
410 stub.name = "(pending?)";
411 groups_.emplace(groupId, std::move(stub));
412 g = getGroup(groupId);
413 }
414
415 g->messages.push_back(std::move(msg));
416
417 const bool isActive = (activeGroupId_ == groupId);
418 if (!isActive || !chatWindowFocused_ || !followScroll_)
419 g->unread = std::min<uint32_t>(g->unread + 1, 999u);
420
422 "LOCAL append gid=" + std::to_string(groupId) + " user=" + username +
423 " text=\"" + text + "\"");
424}
425
426// ====== emitters ======
427
429{
430 auto nm = network_.lock();
431 if (!nm || !hasCurrent())
432 return;
433
434 auto j = msg::makeChatGroupCreate(currentTableId_, g.id, g.name, g.participants);
435
436 if (g.id == generalGroupId_)
437 {
438 nm->broadcastChatJson(j);
439 return;
440 }
441
442 auto targets = resolvePeerIdsForParticipants(g.participants);
443 if (!targets.empty())
444 nm->sendChatJsonTo(targets, j);
445}
446
448{
449 auto nm = network_.lock();
450 if (!nm || !hasCurrent())
451 return;
452
453 auto j = msg::makeChatGroupUpdate(currentTableId_, g.id, g.name, g.participants);
454
455 if (g.id == generalGroupId_)
456 {
457 nm->broadcastChatJson(j);
458 return;
459 }
460
461 auto targets = resolvePeerIdsForParticipants(g.participants);
462 if (!targets.empty())
463 nm->sendChatJsonTo(targets, j);
464}
465
466void ChatManager::emitGroupDelete(uint64_t groupId)
467{
468 auto nm = network_.lock();
469 if (!nm || !hasCurrent())
470 return;
471
472 if (groupId == generalGroupId_)
473 return;
474
475 auto it = groups_.find(groupId);
476 if (it == groups_.end())
477 return;
478
479 const auto& g = it->second;
480
481 auto j = msg::makeChatGroupDelete(currentTableId_, groupId);
482
483 auto targets = resolvePeerIdsForParticipants(g.participants);
484
485 if (!targets.empty())
486 nm->sendChatJsonTo(targets, j);
487}
488
489void ChatManager::emitChatMessageFrame(uint64_t groupId, const std::string& username, const std::string& text, uint64_t ts)
490{
491 auto nm = network_.lock();
492 if (!nm || !hasCurrent())
493 return;
494
495 auto it = groups_.find(groupId);
496 if (it == groups_.end())
497 return;
498
499 const auto& parts = it->second.participants;
500 auto j = msg::makeChatMessage(currentTableId_, groupId, ts, username, text);
501 if (groupId == generalGroupId_)
502 {
503 nm->broadcastChatJson(j);
504 return;
505 }
506 auto targets = resolvePeerIdsForParticipants(parts);
507 if (!targets.empty())
508 nm->sendChatJsonTo(targets, j);
509}
510
511void ChatManager::emitGroupLeave(uint64_t groupId)
512{
513 auto nm = network_.lock();
514 if (!nm || !hasCurrent())
515 return;
516
517 // General cannot be “left”
518 if (groupId == generalGroupId_)
519 return;
520
521 auto it = groups_.find(groupId);
522 if (it == groups_.end())
523 return;
524
525 auto& g = it->second;
526 if (!identity_manager)
527 return;
528
529 const std::string meUid = identity_manager->myUniqueId();
530
531 // Owner cannot leave their own group
532 if (!g.ownerUniqueId.empty() && g.ownerUniqueId == meUid)
533 return;
534
535 // Remove me locally
536 g.participants.erase(meUid);
537
538 // Broadcast updated participants (re-using ChatGroupUpdate)
539 auto j = msg::makeChatGroupUpdate(currentTableId_, g.id, g.name, g.participants);
540 auto targets = resolvePeerIdsForParticipants(g.participants);
541 if (!targets.empty())
542 nm->sendChatJsonTo(targets, j);
543
544 // UX: if I’m no longer in it, stop showing it as active
545 if (activeGroupId_ == groupId && g.participants.count(meUid) == 0)
547}
548
549std::set<std::string> ChatManager::resolvePeerIdsForParticipants(const std::set<std::string>& participantUids) const
550{
551 std::set<std::string> out;
552 auto nm = network_.lock();
553 if (!nm || !identity_manager)
554 return out;
555
556 for (const auto& uid : participantUids)
557 {
558 if (auto pid = identity_manager->peerForUnique(uid); pid && !pid->empty())
559 {
560 out.insert(*pid);
561 }
562 }
563 return out;
564}
565
566void ChatManager::replaceUsernameForUnique(const std::string& uniqueId,
567 const std::string& newUsername)
568{
569 // update cached labels in message history for this author
570 for (auto& [gid, g] : groups_)
571 {
572 // optional: if you show "owner" anywhere:
573 if (g.ownerUniqueId == uniqueId)
574 {
575 // no change to g.name; but if you render "owned by X", use identity_ at draw time.
576 }
577
578 for (auto& m : g.messages)
579 {
580 if (m.senderUniqueId == uniqueId)
581 m.username = newUsername; // cached label shown in UI
582 }
583 }
584}
585
586// ====== UI ======
587/*
588const float fullW = ImGui::GetContentRegionAvail().x;
589 const float fullH = ImGui::GetContentRegionAvail().y;
590
591 ImGui::BeginChild("##Dir", ImVec2(leftWidth_, fullH), true);
592 renderDirectory_(fullH);
593 ImGui::EndChild();
594
595 ImGui::SameLine();
596 ImGui::InvisibleButton("##splitter", ImVec2(3, fullH));
597 if (ImGui::IsItemActive())
598 {
599 leftWidth_ += ImGui::GetIO().MouseDelta.x;
600 leftWidth_ = std::clamp(leftWidth_, 180.0f, fullW - 240.0f);
601 }
602 ImGui::SameLine();
603*/
605{
606 if (!hasCurrent())
607 return;
608
609 ImGui::Begin("ChatWindow");
610 chatWindowFocused_ = ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows | ImGuiFocusedFlags_RootWindow);
611
612 const float leftWmin = 170.0f;
613
614 const float fullW = ImGui::GetContentRegionAvail().x;
615 const float fullH = ImGui::GetContentRegionAvail().y;
616
617 ImGui::BeginChild("Left", ImVec2(leftWidth_, 0), true, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize);
619 ImGui::EndChild();
620
621 ImGui::SameLine();
622 ImGui::InvisibleButton("##splitter", ImVec2(6, fullH));
623 if (ImGui::IsItemActive())
624 {
625 leftWidth_ += ImGui::GetIO().MouseDelta.x;
626 leftWidth_ = std::clamp(leftWidth_, leftWmin, fullW - 240.0f);
627 }
628 ImGui::SameLine();
629
630 ImGui::BeginChild("Right", ImVec2(0, 0), true, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize);
632 ImGui::EndChild();
633
634 ImGui::End();
635}
636
637ImVec4 ChatManager::HSVtoRGB(float h, float s, float v)
638{
639 if (s <= 0.0f)
640 return ImVec4(v, v, v, 1.0f);
641 h = std::fmodf(h, 1.0f) * 6.0f; // [0,6)
642 int i = (int)h;
643 float f = h - (float)i;
644 float p = v * (1.0f - s);
645 float q = v * (1.0f - s * f);
646 float t = v * (1.0f - s * (1.0f - f));
647 float r = 0, g = 0, b = 0;
648 switch (i)
649 {
650 case 0:
651 r = v;
652 g = t;
653 b = p;
654 break;
655 case 1:
656 r = q;
657 g = v;
658 b = p;
659 break;
660 case 2:
661 r = p;
662 g = v;
663 b = t;
664 break;
665 case 3:
666 r = p;
667 g = q;
668 b = v;
669 break;
670 case 4:
671 r = t;
672 g = p;
673 b = v;
674 break;
675 default:
676 r = v;
677 g = p;
678 b = q;
679 break;
680 }
681 return ImVec4(r, g, b, 1.0f);
682}
683
684ImU32 ChatManager::getUsernameColor(const std::string& name) const
685{
686 // C++14-friendly: no init-statement in if
687 auto it = nameColorCache_.find(name);
688 if (it != nameColorCache_.end())
689 return it->second;
690
691 // hash -> hue
692 std::hash<std::string> H;
693 uint64_t hv = H(name) ^ 0x9E3779B97F4A7C15ull;
694 float hue = (float)((hv % 10000) / 10000.0f); // [0,1)
695
696 // avoid a narrow hue band if desired
697 if (hue > 0.12f && hue < 0.18f)
698 hue += 0.08f;
699 if (hue >= 1.0f)
700 hue -= 1.0f;
701
702 const float sat = 0.65f;
703 const float val = 0.90f;
704
705 ImVec4 rgb = HSVtoRGB(hue, sat, val);
706 ImU32 col = ImGui::GetColorU32(rgb);
707
708 nameColorCache_.emplace(name, col);
709 return col;
710}
711
712void ChatManager::renderColoredUsername(const std::string& name) const
713{
714 const ImU32 col = getUsernameColor(name);
715 ImGui::PushStyleColor(ImGuiCol_Text, col);
716 ImGui::TextUnformatted(name.c_str());
717 ImGui::PopStyleColor();
718}
719
720void ChatManager::renderPlainMessage(const std::string& text) const
721{
722 ImGui::TextWrapped("%s", text.c_str());
723}
724
725void ChatManager::tryHandleSlashCommand(uint64_t threadId, const std::string& input)
726{
727 // /roll NdM(+K) e.g., /roll 4d6+2 or /roll 1d20-5
728 if (input.rfind("/roll", 0) != 0)
729 return;
730
731 auto nm = network_.lock();
732 if (!nm)
733 return;
734 auto uname = nm->getMyUsername();
735 if (uname.empty())
736 uname = "me";
737
738 auto parseInt = [](const std::string& s, size_t& i) -> int
739 {
740 int v = 0;
741 bool any = false;
742 while (i < s.size() && std::isdigit((unsigned char)s[i]))
743 {
744 any = true;
745 v = v * 10 + (s[i] - '0');
746 ++i;
747 }
748 return any ? v : -1;
749 };
750
751 size_t i = 5;
752 while (i < input.size() && std::isspace((unsigned char)input[i]))
753 ++i;
754
755 int N = parseInt(input, i);
756 if (i >= input.size() || input[i] != 'd' || N <= 0)
757 { /*invalid*/
758 return;
759 }
760 ++i;
761 int M = parseInt(input, i);
762 if (M <= 0)
763 return;
764
765 int K = 0;
766 if (i < input.size() && (input[i] == '+' || input[i] == '-'))
767 {
768 bool neg = (input[i] == '-');
769 ++i;
770 int Z = parseInt(input, i);
771 if (Z > 0)
772 K = neg ? -Z : Z;
773 }
774
775 std::mt19937 rng((uint32_t)std::random_device{}());
776 std::uniform_int_distribution<int> dist(1, std::max(1, M));
777
778 int total = 0;
779 std::string rolls;
780 for (int r = 0; r < N; ++r)
781 {
782 int die = dist(rng);
783 total += die;
784 if (!rolls.empty())
785 rolls += ", ";
786 rolls += std::to_string(die);
787 }
788
789 // This command outputs as a chat message from "me"
790 const int modApplied = K;
791 const int finalTotal = total + modApplied;
792
793 const std::string text = "rolled " + std::to_string(N) + "d" + std::to_string(M) +
794 (diceModPerDie_ ? " (per-die mod " : " (mod ") +
795 std::to_string(modApplied) + ")" +
796 " => [" + rolls + "] = " + std::to_string(finalTotal);
797
798 const uint64_t ts = (uint64_t)nowSec();
799 emitChatMessageFrame(threadId, uname, text, ts);
800 pushMessageLocal(threadId, "me", uname, text, (double)ts, /*incoming*/ false);
801}
802
803void ChatManager::renderLeftPanel(float /*width*/)
804{
805 // Top actions
806 if (ImGui::Button("New Group"))
807 openCreatePopup_ = true;
808 ImGui::SameLine();
809 if (ImGui::Button("Delete Group"))
810 openDeletePopup_ = true;
811
812 // Enable edit only when there is a non-General active group
813 bool canEdit = (activeGroupId_ != 0 && activeGroupId_ != generalGroupId_);
814 ImGui::BeginDisabled(!canEdit);
815 if (ImGui::Button("Edit Group"))
816 {
817 // seed edit state from the active group
818 if (auto* g = getGroup(activeGroupId_))
819 {
820 editGroupId_ = g->id;
821 editGroupSel_ = g->participants;
822 std::fill(editGroupName_.begin(), editGroupName_.end(), '\0');
823 std::snprintf(editGroupName_.data(), (int)editGroupName_.size(), "%s", g->name.c_str());
824 openEditPopup_ = true;
825 }
826 }
827
828 ImGui::EndDisabled();
829
830 ImGui::Separator();
831 ImGui::TextUnformatted("Chat Groups");
832 ImGui::Separator();
833
834 // Sorted by name; General at top
835 std::vector<uint64_t> order;
836 order.reserve(groups_.size());
837 for (auto& [id, _] : groups_)
838 order.push_back(id);
839 std::sort(order.begin(), order.end(), [&](uint64_t a, uint64_t b)
840 {
841 if (a == generalGroupId_) return true;
842 if (b == generalGroupId_) return false;
843 return groups_[a].name < groups_[b].name; });
844
845 for (auto id : order)
846 {
847 auto& g = groups_[id];
848 const bool selected = (activeGroupId_ == id);
849
850 ImGui::PushID((int)id);
851 if (ImGui::Selectable(g.name.c_str(), selected, ImGuiSelectableFlags_SpanAllColumns, ImVec2(0, 0)))
852 {
853 activeGroupId_ = id;
854 markGroupRead(id);
855 focusInput_ = true;
856 followScroll_ = true;
857 }
858
859 if (g.unread > 0)
860 {
861 ImVec2 min = ImGui::GetItemRectMin();
862 ImVec2 max = ImGui::GetItemRectMax();
863 ImVec2 size(max.x - min.x, max.y - min.y);
864
865 char buf[16];
866 if (g.unread < 100)
867 std::snprintf(buf, sizeof(buf), "%u", g.unread);
868 else
869 std::snprintf(buf, sizeof(buf), "99+");
870
871 ImVec2 txtSize = ImGui::CalcTextSize(buf);
872 const float padX = 6.f, padY = 3.f;
873 const float radius = (txtSize.y + padY * 2.f) * 0.5f;
874 const float marginRight = 8.f;
875 ImVec2 center(max.x - marginRight - radius, min.y + size.y * 0.5f);
876
877 auto* dl = ImGui::GetWindowDrawList();
878 ImU32 bg = ImGui::GetColorU32(ImVec4(0.10f, 0.70f, 0.30f, 1.0f));
879 ImU32 fg = ImGui::GetColorU32(ImVec4(1, 1, 1, 1));
880
881 ImVec2 badgeMin(center.x - std::max(radius, txtSize.x * 0.5f + padX), center.y - radius);
882 ImVec2 badgeMax(center.x + std::max(radius, txtSize.x * 0.5f + padX), center.y + radius);
883 dl->AddRectFilled(badgeMin, badgeMax, bg, radius);
884 ImVec2 textPos(center.x - txtSize.x * 0.5f, center.y - txtSize.y * 0.5f);
885 dl->AddText(textPos, fg, buf);
886 }
887 ImGui::PopID();
888 }
889
891 {
892 openCreatePopup_ = false;
893 ImGui::OpenPopup("CreateGroup");
894 }
896
898 {
899 openDeletePopup_ = false;
900 ImGui::OpenPopup("DeleteGroup");
901 }
903
904 if (openEditPopup_)
905 {
906 openEditPopup_ = false;
907 ImGui::OpenPopup("EditGroup");
908 }
910}
911
912void ChatManager::renderRightPanel(float /*leftW*/)
913{
914 auto* g = getGroup(activeGroupId_);
915 const float footerRowH = ImGui::GetFrameHeightWithSpacing() * 2.0f;
916
917 ImVec2 avail = ImGui::GetContentRegionAvail();
918 ImGui::BeginChild("Messages", ImVec2(0, avail.y - footerRowH), true, ImGuiWindowFlags_AlwaysVerticalScrollbar);
919 if (g)
920 {
921 if (ImGui::GetScrollY() < ImGui::GetScrollMaxY() - 1.0f)
922 followScroll_ = false;
923
924 for (auto& m : g->messages)
925 {
926 renderColoredUsername(m.username);
927
928 // separator
929 ImGui::SameLine(0.0f, 6.0f);
930 ImGui::TextUnformatted("-");
931 ImGui::SameLine(0.0f, 6.0f);
932
933 // message (wrapped). Put it in a child region so the wrap can use full width if you want,
934 // but simplest is to just call TextWrapped now:
935 // ensure we start from current cursor X (SameLine set it)
936 ImGui::PushTextWrapPos(0);
937 renderPlainMessage(m.content);
938 ImGui::PopTextWrapPos();
939 }
940 if (followScroll_)
941 ImGui::SetScrollHereY(1.0f);
942 if (jumpToBottom_)
943 {
944 ImGui::SetScrollHereY(1.0f);
945 jumpToBottom_ = false;
946 }
947 }
948 ImGui::EndChild();
949
950 // Footer input
951 ImGui::BeginChild("Footer", ImVec2(0, 0), false, ImGuiWindowFlags_AlwaysAutoResize);
952
953 if (focusInput_)
954 {
955 ImGui::SetKeyboardFocusHere();
956 focusInput_ = false;
957 }
958
959 ImGuiInputTextFlags flags = ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_AllowTabInput;
960 if (ImGui::InputText("##chat_input", input_.data(), (int)input_.size(), flags))
961 {
962 std::string text(input_.data());
963 if (!text.empty() && g)
964 {
965 const uint64_t ts = (uint64_t)nowSec();
966 auto nm = network_.lock();
967 auto uname = nm ? nm->getMyUsername() : std::string("me");
968 if (uname.empty())
969 uname = "me";
970
971 if (!text.empty() && text[0] == '/')
972 {
974 }
975 else
976 {
977 emitChatMessageFrame(g->id, uname, text, ts);
978 pushMessageLocal(g->id, "me", uname, text, (double)ts, false);
979 }
980
981 input_.fill('\0');
982 followScroll_ = true;
983 focusInput_ = true;
984 markGroupRead(g->id);
985 }
986 }
988 ImGui::SameLine();
989 if (ImGui::Button("Send") && g)
990 {
991 std::string text(input_.data());
992 if (!text.empty())
993 {
994 const uint64_t ts = (uint64_t)nowSec();
995 auto nm = network_.lock();
996 auto uname = nm ? nm->getMyUsername() : std::string("me");
997 if (uname.empty())
998 uname = "me";
999
1000 emitChatMessageFrame(g->id, uname, text, ts);
1001 pushMessageLocal(g->id, "me", uname, text, (double)ts, false);
1002
1003 input_.fill('\0');
1004 followScroll_ = true;
1005 markGroupRead(g->id);
1006 }
1007 }
1008
1009 ImGui::BeginDisabled(followScroll_);
1010 if (ImGui::Button("Go to bottom"))
1011 {
1012 jumpToBottom_ = true;
1013 }
1014 ImGui::EndDisabled();
1015 ImGui::SameLine();
1016 if (ImGui::Button("Roll Dice"))
1017 {
1018 openDicePopup_ = true;
1019 }
1020 if (openDicePopup_)
1021 {
1022 openDicePopup_ = false;
1023 ImGui::OpenPopup("DiceRoller");
1024 }
1026
1027 ImGui::EndChild();
1028}
1029//
1030//void ChatManager::renderCreateGroupPopup()
1031//{
1032// if (ImGui::BeginPopupModal("CreateGroup", nullptr, ImGuiWindowFlags_AlwaysAutoResize))
1033// {
1034// ImGui::TextUnformatted("Group name (unique):");
1035// ImGui::InputText("##gname", newGroupName_.data(), (int)newGroupName_.size());
1036//
1037// // list peers
1038// ImGui::Separator();
1039// ImGui::TextUnformatted("Participants:");
1040// if (auto nm = network_.lock())
1041// {
1042// for (auto& [pid, link] : nm->getPeers())
1043// {
1044// bool checked = newGroupSel_.count(pid) > 0;
1045// const std::string dn = nm->displayNameForPeer(pid);
1046// std::string label = dn.empty() ? pid : (dn + " (" + pid + ")");
1047// if (ImGui::Checkbox(label.c_str(), &checked))
1048// {
1049// if (checked)
1050// newGroupSel_.insert(pid);
1051// else
1052// newGroupSel_.erase(pid);
1053// }
1054// }
1055// }
1056//
1057// // ensure name unique
1058// bool nameTaken = false;
1059// std::string desired = newGroupName_.data();
1060// if (!desired.empty())
1061// {
1062// for (auto& [id, g] : groups_)
1063// if (g.name == desired)
1064// {
1065// nameTaken = true;
1066// break;
1067// }
1068// }
1069//
1070// ImGui::Separator();
1071// ImGui::BeginDisabled(desired.empty() || nameTaken);
1072// if (ImGui::Button("Create"))
1073// {
1074// ChatGroupModel g;
1075// g.name = desired;
1076// g.id = makeGroupIdFromName(g.name);
1077// g.participants = newGroupSel_;
1078// if (identity_manager)
1079// g.ownerUniqueId = identity_manager->myUniqueId();
1080//
1081// groups_.emplace(g.id, g);
1082// emitGroupCreate(g);
1083//
1084// activeGroupId_ = g.id;
1085// markGroupRead(g.id);
1086// focusInput_ = true;
1087// followScroll_ = true;
1088//
1089// newGroupSel_.clear();
1090// newGroupName_.fill('\0');
1091// ImGui::CloseCurrentPopup();
1092// }
1093// ImGui::EndDisabled();
1094//
1095// if (nameTaken)
1096// ImGui::TextColored(ImVec4(1, 0.4f, 0.2f, 1), "A group with that name already exists.");
1097//
1098// ImGui::SameLine();
1099// if (ImGui::Button("Cancel"))
1100// {
1101// newGroupSel_.clear();
1102// newGroupName_.fill('\0');
1103// ImGui::CloseCurrentPopup();
1104// }
1105// ImGui::EndPopup();
1106// }
1107//}
1108
1110{
1111 if (ImGui::BeginPopupModal("CreateGroup", nullptr, ImGuiWindowFlags_AlwaysAutoResize))
1112 {
1113 ImGui::TextUnformatted("Group name (unique):");
1114 ImGui::InputText("##gname", newGroupName_.data(), (int)newGroupName_.size());
1116 // list peers
1117 ImGui::Separator();
1118 ImGui::TextUnformatted("Participants:");
1119 if (auto nm = network_.lock())
1120 {
1121 for (auto& [pid, link] : nm->getPeers())
1122 {
1123 // 🔸 map peerId -> uniqueId for selection storage
1124 std::string uid = identity_manager
1125 ? identity_manager->uniqueForPeer(pid).value_or(std::string{})
1126 : std::string{};
1127
1128 if (uid.empty())
1129 continue; // not bound yet; skip or show disabled if you prefer
1130
1131 bool checked = newGroupSel_.count(uid) > 0; // 🔸 store UNIQUE IDs
1132 const std::string dn = nm->displayNameForPeer(pid);
1133 std::string label = dn.empty() ? pid : (dn + " (" + pid + ")");
1134 if (ImGui::Checkbox(label.c_str(), &checked))
1135 {
1136 if (checked)
1137 newGroupSel_.insert(uid); // 🔸 insert UID
1138 else
1139 newGroupSel_.erase(uid); // 🔸 erase UID
1140 }
1141 }
1142 }
1143
1144 // ensure name unique
1145 bool nameTaken = false;
1146 std::string desired = newGroupName_.data();
1147 if (!desired.empty())
1148 {
1149 for (auto& [id, g] : groups_)
1150 if (g.name == desired)
1151 {
1152 nameTaken = true;
1153 break;
1154 }
1155 }
1156
1157 ImGui::Separator();
1158 ImGui::BeginDisabled(desired.empty() || nameTaken);
1159 if (ImGui::Button("Create"))
1160 {
1162 g.name = desired;
1163 g.id = makeGroupIdFromName(g.name);
1164 g.participants = newGroupSel_; // 🔸 UNIQUE IDs
1165
1166 if (identity_manager)
1167 {
1168 g.ownerUniqueId = identity_manager->myUniqueId();
1169 if (!g.ownerUniqueId.empty())
1170 g.participants.insert(g.ownerUniqueId); // 🔸 owner always included (solo OK)
1171 }
1172
1173 groups_.emplace(g.id, g);
1174 emitGroupCreate(g);
1175
1176 activeGroupId_ = g.id;
1177 markGroupRead(g.id);
1178 focusInput_ = true;
1179 followScroll_ = true;
1180
1181 newGroupSel_.clear();
1182 newGroupName_.fill('\0');
1183 ImGui::CloseCurrentPopup();
1184 }
1185 ImGui::EndDisabled();
1186
1187 if (nameTaken)
1188 ImGui::TextColored(ImVec4(1, 0.4f, 0.2f, 1), "A group with that name already exists.");
1189
1190 ImGui::SameLine();
1191 if (ImGui::Button("Cancel"))
1192 {
1193 newGroupSel_.clear();
1194 newGroupName_.fill('\0');
1195 ImGui::CloseCurrentPopup();
1196 }
1197 ImGui::EndPopup();
1198 }
1199}
1200//
1201//void ChatManager::renderEditGroupPopup()
1202//{
1203// if (ImGui::BeginPopupModal("EditGroup", nullptr, ImGuiWindowFlags_AlwaysAutoResize))
1204// {
1205// ChatGroupModel* g = getGroup(editGroupId_);
1206// if (!g)
1207// {
1208// ImGui::TextDisabled("Group not found.");
1209// if (ImGui::Button("Close"))
1210// ImGui::CloseCurrentPopup();
1211// ImGui::EndPopup();
1212// return;
1213// }
1214//
1215// // Optional: only owner can edit (comment out these 6 lines if you want anyone to edit)
1216// auto nm = network_.lock();
1217// const std::string meUid = identity_manager ? identity_manager->myUniqueId() : "";
1218// const bool ownerOnly = true;
1219// const bool canEdit = !ownerOnly || (!g->ownerUniqueId.empty() && g->ownerUniqueId == meUid);
1220// if (!canEdit)
1221// ImGui::TextColored(ImVec4(1, 0.5f, 0.3f, 1), "Only the owner can edit this group.");
1222//
1223// ImGui::Separator();
1224// ImGui::TextUnformatted("Group name:");
1225// ImGui::BeginDisabled(!canEdit);
1226// ImGui::InputText("##edit_gname", editGroupName_.data(), (int)editGroupName_.size());
1227// ImGui::EndDisabled();
1228//
1229// // Participants list (pre-checked)
1230// ImGui::Separator();
1231// ImGui::TextUnformatted("Participants:");
1232// if (nm)
1233// {
1234// ImGui::BeginDisabled(!canEdit);
1235// for (auto& [pid, link] : nm->getPeers())
1236// {
1237// bool checked = editGroupSel_.count(pid) > 0;
1238// const std::string dn = nm->displayNameForPeer(pid);
1239// std::string label = dn.empty() ? pid : (dn + " (" + pid + ")");
1240// if (ImGui::Checkbox(label.c_str(), &checked))
1241// {
1242// if (checked)
1243// editGroupSel_.insert(pid);
1244// else
1245// editGroupSel_.erase(pid);
1246// }
1247// }
1248// ImGui::EndDisabled();
1249// }
1250//
1251// // Validate (unique name unless unchanged)
1252// bool nameTaken = false;
1253// const std::string desired = editGroupName_.data();
1254// if (!desired.empty() && desired != g->name)
1255// {
1256// for (auto& [id, gg] : groups_)
1257// if (gg.name == desired)
1258// {
1259// nameTaken = true;
1260// break;
1261// }
1262// }
1263// if (nameTaken)
1264// ImGui::TextColored(ImVec4(1, 0.4f, 0.2f, 1), "A group with that name already exists.");
1265//
1266// ImGui::Separator();
1267// ImGui::BeginDisabled(!canEdit || desired.empty() || nameTaken);
1268// if (ImGui::Button("Update", ImVec2(120, 0)))
1269// {
1270// // Apply local changes
1271// g->name = desired;
1272// g->participants = editGroupSel_;
1273//
1274// // Broadcast update
1275// emitGroupUpdate(*g);
1276//
1277// // nice UX
1278// activeGroupId_ = g->id;
1279// markGroupRead(g->id);
1280// focusInput_ = true;
1281// followScroll_ = true;
1282//
1283// // keep popup open OR close—your call; let's close:
1284// ImGui::CloseCurrentPopup();
1285// }
1286// ImGui::EndDisabled();
1287//
1288// ImGui::SameLine();
1289// if (ImGui::Button("Close", ImVec2(120, 0)))
1290// {
1291// ImGui::CloseCurrentPopup();
1292// }
1293//
1294// ImGui::EndPopup();
1295// }
1296//}
1297
1299{
1300 if (ImGui::BeginPopupModal("EditGroup", nullptr, ImGuiWindowFlags_AlwaysAutoResize))
1301 {
1303 if (!g)
1304 {
1305 ImGui::TextDisabled("Group not found.");
1306 if (ImGui::Button("Close"))
1307 ImGui::CloseCurrentPopup();
1308 ImGui::EndPopup();
1309 return;
1310 }
1311
1312 // Optional: only owner can edit
1313 auto nm = network_.lock();
1314 const std::string meUid = identity_manager ? identity_manager->myUniqueId() : "";
1315 const bool ownerOnly = true;
1316 const bool canEdit = !ownerOnly || (!g->ownerUniqueId.empty() && g->ownerUniqueId == meUid);
1317 if (!canEdit)
1318 ImGui::TextColored(ImVec4(1, 0.5f, 0.3f, 1), "Only the owner can edit this group.");
1319
1320 ImGui::Separator();
1321 ImGui::TextUnformatted("Group name:");
1322 ImGui::BeginDisabled(!canEdit);
1323 ImGui::InputText("##edit_gname", editGroupName_.data(), (int)editGroupName_.size());
1325 ImGui::EndDisabled();
1326
1327 // Participants list (pre-checked)
1328 ImGui::Separator();
1329 ImGui::TextUnformatted("Participants:");
1330 if (nm)
1331 {
1332 ImGui::BeginDisabled(!canEdit);
1333 for (auto& [pid, link] : nm->getPeers())
1334 {
1335 // 🔸 map peerId -> uniqueId for selection storage
1336 std::string uid = identity_manager
1337 ? identity_manager->uniqueForPeer(pid).value_or(std::string{})
1338 : std::string{};
1339
1340 if (uid.empty())
1341 continue;
1342
1343 bool checked = editGroupSel_.count(uid) > 0; // 🔸 use UID
1344 const std::string dn = nm->displayNameForPeer(pid);
1345 std::string label = dn.empty() ? pid : (dn + " (" + pid + ")");
1346 if (ImGui::Checkbox(label.c_str(), &checked))
1347 {
1348 if (checked)
1349 editGroupSel_.insert(uid); // 🔸 insert UID
1350 else
1351 editGroupSel_.erase(uid); // 🔸 erase UID
1352 }
1353 }
1354 ImGui::EndDisabled();
1355 }
1356
1357 // Validate (unique name unless unchanged)
1358 bool nameTaken = false;
1359 const std::string desired = editGroupName_.data();
1360 if (desired.empty() == false && desired != g->name)
1361 {
1362 for (auto& [id, gg] : groups_)
1363 if (gg.name == desired)
1364 {
1365 nameTaken = true;
1366 break;
1367 }
1368 }
1369 if (nameTaken)
1370 ImGui::TextColored(ImVec4(1, 0.4f, 0.2f, 1), "A group with that name already exists.");
1371
1372 ImGui::Separator();
1373 ImGui::BeginDisabled(!canEdit || desired.empty() || nameTaken);
1374 if (ImGui::Button("Update", ImVec2(120, 0)))
1375 {
1376 // Apply local changes
1377 g->name = desired;
1378 g->participants = editGroupSel_; // 🔸 UNIQUE IDs
1379
1380 // Broadcast update
1381 emitGroupUpdate(*g);
1382
1383 // nice UX
1384 activeGroupId_ = g->id;
1385 markGroupRead(g->id);
1386 focusInput_ = true;
1387 followScroll_ = true;
1388
1389 ImGui::CloseCurrentPopup();
1390 }
1391 ImGui::EndDisabled();
1392
1393 ImGui::SameLine();
1394 if (ImGui::Button("Close", ImVec2(120, 0)))
1395 ImGui::CloseCurrentPopup();
1396
1397 ImGui::EndPopup();
1398 }
1399}
1400//
1401//void ChatManager::renderDeleteGroupPopup()
1402//{
1403// if (ImGui::BeginPopupModal("DeleteGroup", nullptr, ImGuiWindowFlags_AlwaysAutoResize))
1404// {
1405// ImGui::TextUnformatted("Click a group to delete (General cannot be deleted).");
1406// ImGui::Separator();
1407//
1408// for (auto it = groups_.begin(); it != groups_.end(); /*++ inside*/)
1409// {
1410// auto id = it->first;
1411// auto& g = it->second;
1412// if (id == generalGroupId_)
1413// {
1414// ++it;
1415// continue;
1416// }
1417//
1418// ImGui::PushID((int)id);
1419// const std::string meUid = identity_manager ? identity_manager->myUniqueId() : "";
1420// const bool canDelete = (!g.ownerUniqueId.empty() && g.ownerUniqueId == meUid);
1421// ImGui::BeginDisabled(!canDelete);
1422// if (ImGui::Button(g.name.c_str(), ImVec2(220, 0)))
1423// {
1424// emitGroupDelete(id);
1425// if (activeGroupId_ == id)
1426// activeGroupId_ = generalGroupId_;
1427// it = groups_.erase(it);
1428// ImGui::PopID();
1429// continue;
1430// }
1431// ImGui::EndDisabled();
1432// if (!canDelete)
1433// {
1434// ImGui::SameLine();
1435// ImGui::TextDisabled("(owner only)");
1436// }
1437// ImGui::PopID();
1438// ++it;
1439// }
1440//
1441// ImGui::Separator();
1442// if (ImGui::Button("Close"))
1443// ImGui::CloseCurrentPopup();
1444// ImGui::EndPopup();
1445// }
1446//}
1447
1449{
1450 if (ImGui::BeginPopupModal("DeleteGroup", nullptr, ImGuiWindowFlags_AlwaysAutoResize))
1451 {
1452 ImGui::TextUnformatted("Manage groups you participate in (General cannot be deleted/left).");
1453 ImGui::Separator();
1454
1455 // Who am I and am I GM?
1456 std::string meUid = identity_manager ? identity_manager->myUniqueId() : "";
1457 bool iAmGM = false;
1458 if (auto nm = network_.lock())
1459 {
1460 // Use the accessor you actually have: getGMId() / gmId() / GMId() — pick the right one.
1461 // If your NetworkManager exposes a different name, change here only.
1462 const std::string gmUid = nm->getGMId(); // <-- adjust if needed in your codebase
1463 iAmGM = (!gmUid.empty() && !meUid.empty() && gmUid == meUid);
1464 }
1465
1466 for (auto it = groups_.begin(); it != groups_.end(); /* ++ inside */)
1467 {
1468 const uint64_t id = it->first;
1469 ChatGroupModel& g = it->second;
1470
1471 // Skip General
1472 if (id == generalGroupId_)
1473 {
1474 ++it;
1475 continue;
1476 }
1477
1478 // Only show groups where I’m currently a participant (so I can Leave/Delete)
1479 const bool iParticipate = isMeParticipantOf(g);
1480
1481 // Compute permissions
1482 const bool isOwner = (!g.ownerUniqueId.empty() && g.ownerUniqueId == meUid);
1483 const bool canDelete = isOwner;
1484 const bool canLeave = (iParticipate && !isOwner); // owners cannot leave by rule
1485
1486 ImGui::PushID((int)id);
1487
1488 // Row label
1489 ImGui::TextUnformatted(g.name.c_str());
1490 ImGui::SameLine();
1491
1492 // DELETE button
1493 ImGui::BeginDisabled(!canDelete);
1494 if (ImGui::Button("Delete"))
1495 {
1496 // Emit delete to participants
1497 emitGroupDelete(id);
1498
1499 // Remove locally
1500 if (activeGroupId_ == id)
1502 it = groups_.erase(it);
1503
1504 ImGui::PopID();
1505 continue; // skip ++it
1506 }
1507 ImGui::EndDisabled();
1508
1509 ImGui::SameLine();
1510
1511 // LEAVE button
1512 ImGui::BeginDisabled(!canLeave);
1513 if (ImGui::Button("Leave"))
1514 {
1515 emitGroupLeave(id);
1516
1517 // If I’m no longer a participant, optionally hide it locally right away
1518 if (!isMeParticipantOf(g))
1519 {
1520 if (activeGroupId_ == id)
1522 it = groups_.erase(it);
1523 ImGui::PopID();
1524 continue; // skip ++it
1525 }
1526 }
1527 ImGui::EndDisabled();
1528
1529 // Helper hints
1530 if (!iParticipate)
1531 {
1532 ImGui::SameLine();
1533 ImGui::TextDisabled("(not a participant)");
1534 }
1535 else if (isOwner)
1536 {
1537 ImGui::SameLine();
1538 ImGui::TextDisabled("(owner)");
1539 }
1540 else if (iAmGM)
1541 {
1542 ImGui::SameLine();
1543 ImGui::TextDisabled("(GM)");
1544 }
1545
1546 ImGui::PopID();
1547 ++it;
1548 }
1549
1550 ImGui::Separator();
1551 if (ImGui::Button("Close"))
1552 ImGui::CloseCurrentPopup();
1553 ImGui::EndPopup();
1554 }
1555}
1556
1558{
1559 if (ImGui::BeginPopup("DiceRoller"))
1560 {
1561 ImGui::TextUnformatted("Dice Roller");
1562 ImGui::Separator();
1563
1564 ImGui::InputInt("Number", &diceN_);
1566 ImGui::InputInt("Sides", &diceSides_);
1568 ImGui::InputInt("Modifier", &diceMod_);
1570 ImGui::Checkbox("Apply modifier per die", &diceModPerDie_);
1571
1572 if (ImGui::Button("d4"))
1573 diceSides_ = 4;
1574 ImGui::SameLine();
1575 if (ImGui::Button("d6"))
1576 diceSides_ = 6;
1577 ImGui::SameLine();
1578 if (ImGui::Button("d8"))
1579 diceSides_ = 8;
1580 ImGui::SameLine();
1581 if (ImGui::Button("d10"))
1582 diceSides_ = 10;
1583 ImGui::SameLine();
1584 if (ImGui::Button("d12"))
1585 diceSides_ = 12;
1586 ImGui::SameLine();
1587 if (ImGui::Button("d20"))
1588 diceSides_ = 20;
1589
1590 ImGui::Separator();
1591 if (ImGui::Button("Roll"))
1592 {
1594 //int N = std::max(1, diceN_);
1595 //int M = std::max(1, diceSides_);
1596 //int K = diceModPerDie_ ? (diceMod_ * N) : diceMod_;
1597 //std::string text = "/roll " + std::to_string(N) + "d" + std::to_string(M) +
1598 // (K > 0 ? "+" + std::to_string(K) : (K < 0 ? std::string("-") + std::to_string(-K) : ""));
1600 //auto nm = network_.lock();
1601 //std::string uname = nm ? nm->getMyUsername() : "me";
1602 //if (uname.empty())
1603 // uname = "me";
1604 // build slash command
1605 int mod = diceMod_;
1606 int N = std::max(1, diceN_);
1607 int M = std::max(1, diceSides_);
1608 // If per-die modifier, convert into +K where K = modifier * N for the final text.
1609 int K = diceModPerDie_ ? (mod * N) : mod;
1610 std::string cmd = "/roll " + std::to_string(N) + "d" + std::to_string(M);
1611 if (K > 0)
1612 cmd += "+" + std::to_string(K);
1613 else if (K < 0)
1614 cmd += "-" + std::to_string(-K);
1616
1617 ImGui::CloseCurrentPopup();
1618 }
1619 ImGui::SameLine();
1620 if (ImGui::Button("Cancel"))
1621 ImGui::CloseCurrentPopup();
1622
1623 ImGui::EndPopup();
1624 }
1625}
1626
1627void ChatManager::markGroupRead(uint64_t groupId)
1628{
1629 if (auto* g = getGroup(groupId))
1630 g->unread = 0;
1631}
1632
1634//void ChatManager::writeGroupsToSnapshotGT(std::vector<unsigned char>& buf) const
1635//{
1636// // light snapshot (optional): just groups’ metadata; messages are local-only
1637// Serializer::serializeInt(buf, 1); // version
1638// Serializer::serializeInt(buf, (int)groups_.size());
1639// for (auto& [id, g] : groups_)
1640// {
1641// Serializer::serializeUInt64(buf, g.id);
1642// Serializer::serializeString(buf, g.name);
1643// Serializer::serializeInt(buf, (int)g.participants.size());
1644// for (auto& p : g.participants)
1645// Serializer::serializeString(buf, p);
1646// }
1647//}
1648//
1649//void ChatManager::readGroupsFromSnapshotGT(const std::vector<unsigned char>& buf, size_t& off)
1650//{
1651// groups_.clear();
1652// ensureGeneral();
1653// (void)Serializer::deserializeInt(buf, off); // version
1654// int n = Serializer::deserializeInt(buf, off);
1655// for (int i = 0; i < n; ++i)
1656// {
1657// ChatGroupModel g;
1658// g.id = Serializer::deserializeUInt64(buf, off);
1659// g.name = Serializer::deserializeString(buf, off);
1660// int pc = Serializer::deserializeInt(buf, off);
1661// for (int k = 0; k < pc; ++k)
1662// g.participants.insert(Serializer::deserializeString(buf, off));
1663// groups_.emplace(g.id, std::move(g));
1664// }
1665// if (groups_.find(activeGroupId_) == groups_.end())
1666// activeGroupId_ = generalGroupId_;
1667//}
1668
1669//ALWAYS BROADCAST EVERYTHING
1670/*void ChatManager::emitGroupCreate(const ChatGroupModel& g)
1671{
1672 auto nm = network_.lock();
1673 if (!nm || !hasCurrent())
1674 return;
1675
1676 auto j = msg::makeChatGroupCreate(currentTableId_, g.id, g.name, g.participants);
1677 Logger::instance().log("chat", Logger::Level::Info,
1678 "SEND ChatGroupCreate id=" + std::to_string(g.id) + " name=" + g.name);
1679 // nm->broadcastChatJson(j); // broadcast groups list to everyone
1680 nm->sendChatJsonTo(g.participants, j);
1681}
1682
1683void ChatManager::emitGroupUpdate(const ChatGroupModel& g)
1684{
1685 auto nm = network_.lock();
1686 if (!nm || !hasCurrent())
1687 return;
1688
1689 auto j = msg::makeChatGroupUpdate(currentTableId_, g.id, g.name, g.participants);
1690 // nm->broadcastChatJson(j); //
1691 nm->sendChatJsonTo(g.participants, j);
1692}
1693
1694void ChatManager::emitGroupDelete(uint64_t groupId)
1695{
1696 auto nm = network_.lock();
1697 if (!nm || !hasCurrent())
1698 return;
1699
1700 auto j = msg::makeChatGroupDelete(currentTableId_, groupId);
1701 nm->broadcastChatJson(j);
1702 nm->sendChatJsonTo(g.participants, j);
1703}
1704
1705void ChatManager::emitChatMessageFrame(uint64_t groupId, const std::string& username, const std::string& text, uint64_t ts)
1706{
1707 auto nm = network_.lock();
1708 if (!nm || !hasCurrent())
1709 return;
1710
1711 auto j = msg::makeChatMessage(currentTableId_, groupId, ts, username, text);
1712 // nm->broadcastChatJson(j); //
1713 nm->sendChatJsonTo(g.participants, j);
1714}*/
static bool ends_with_icase(const std::string &a, const char *suf)
void renderRightPanel(float leftPanelWidth)
uint64_t makeGroupIdFromName(const std::string &name) const
void renderPlainMessage(const std::string &text) const
void pushMessageLocal(uint64_t groupId, const std::string &fromPeer, const std::string &username, const std::string &text, double ts, bool incoming)
bool saveCurrent()
std::array< char, 512 > input_
ChatGroupModel * getGroup(uint64_t id)
void renderEditGroupPopup()
bool loadCurrent()
static double nowSec()
std::set< std::string > resolvePeerIdsForParticipants(const std::set< std::string > &participantUids) const
void renderCreateGroupPopup()
float leftWidth_
std::array< char, 128 > editGroupName_
std::set< std::string > newGroupSel_
static ImVec4 HSVtoRGB(float h, float s, float v)
void emitGroupLeave(uint64_t groupId)
void tryHandleSlashCommand(uint64_t threadId, const std::string &input)
void renderDicePopup()
bool openEditPopup_
void emitChatMessageFrame(uint64_t groupId, const std::string &username, const std::string &text, uint64_t ts)
void renderColoredUsername(const std::string &name) const
ImU32 getUsernameColor(const std::string &name) const
void setActiveGameTable(uint64_t tableId, const std::string &gameTableName)
bool chatWindowFocused_
std::set< std::string > editGroupSel_
void emitGroupUpdate(const ChatGroupModel &g)
void replaceUsernameForUnique(const std::string &uniqueId, const std::string &newUsername)
std::filesystem::path chatFilePathFor(uint64_t tableId, const std::string &name) const
bool openCreatePopup_
std::unordered_map< std::string, ImU32 > nameColorCache_
std::string currentTableName_
Definition ChatManager.h:91
bool isMeParticipantOf(const ChatGroupModel &g) const
bool followScroll_
uint64_t activeGroupId_
void renderDeleteGroupPopup()
bool jumpToBottom_
bool saveLog(std::vector< uint8_t > &buf) const
static ChatMessageModel::Kind classifyMessage(const std::string &s)
uint64_t editGroupId_
std::unordered_map< uint64_t, ChatGroupModel > groups_
uint64_t currentTableId_
Definition ChatManager.h:90
static constexpr uint64_t generalGroupId_
Definition ChatManager.h:89
void ensureGeneral()
bool openDicePopup_
void emitGroupCreate(const ChatGroupModel &g)
bool loadLog(const std::vector< uint8_t > &buf)
std::array< char, 128 > newGroupName_
void setNetwork(std::weak_ptr< NetworkManager > nm)
ChatManager(std::weak_ptr< NetworkManager > nm, std::shared_ptr< IdentityManager > identity_manager)
std::shared_ptr< IdentityManager > identity_manager
bool hasCurrent() const
Definition ChatManager.h:59
void markGroupRead(uint64_t groupId)
bool diceModPerDie_
bool openDeletePopup_
void renderLeftPanel(float width)
std::weak_ptr< NetworkManager > network_
void applyReady(const msg::ReadyMessage &m)
void emitGroupDelete(uint64_t groupId)
static Logger & instance()
Definition Logger.h:39
static fs::path getGameTablesPath()
Definition PathManager.h:76
static int deserializeInt(const std::vector< unsigned char > &buffer, size_t &offset)
Definition Serializer.h:367
static std::string deserializeString(const std::vector< unsigned char > &buffer, size_t &offset)
Definition Serializer.h:388
static void serializeUInt64(std::vector< unsigned char > &buffer, uint64_t value)
Definition Serializer.h:331
static void serializeString(std::vector< unsigned char > &buffer, const std::string &str)
Definition Serializer.h:318
static uint64_t deserializeUInt64(const std::vector< unsigned char > &buffer, size_t &offset)
Definition Serializer.h:360
static void serializeInt(std::vector< unsigned char > &buffer, int value)
Definition Serializer.h:302
void TrackThisInput()
Definition Message.h:28
Json makeChatMessage(uint64_t tableId, uint64_t groupId, uint64_t ts, const std::string &username, const std::string &text)
Definition Message.h:404
Json makeChatGroupUpdate(uint64_t tableId, uint64_t groupId, const std::string &name, const std::set< std::string > &participants)
Definition Message.h:381
DCType
Definition Message.h:31
Json makeChatGroupCreate(uint64_t tableId, uint64_t groupId, const std::string &name, const std::set< std::string > &participants)
Definition Message.h:366
Json makeChatGroupDelete(uint64_t tableId, uint64_t groupId)
Definition Message.h:396
std::string name
Definition ChatManager.h:41