12#include <unordered_map>
18 return (
double)std::time(
nullptr);
23 const size_t n = std::strlen(suf);
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); });
34 if (s.rfind(
"http://", 0) == 0 || s.rfind(
"https://", 0) == 0)
41 network_(std::move(nm)), identity_manager(identity_manager) {}
72 g.ownerUniqueId.clear();
73 groups_.emplace(g.id, std::move(g));
81 auto gametable_name_folder = gametables_folder / name;
82 return gametable_name_folder / std::filesystem::path(name +
"_" + std::to_string(tableId) +
"_chatgroups.runic");
98 for (
auto& p : g.participants)
102 for (
auto& m : g.messages)
118 if (magic !=
"RUNIC-CHAT-GROUPS")
125 for (
int i = 0; i < count; ++i)
137 g.ownerUniqueId = legacyOwner;
141 for (
int k = 0; k < pc; ++k)
145 for (
int m = 0; m < mc; ++m)
153 msg.senderUniqueId.clear();
158 g.messages.push_back(std::move(
msg));
162 groups_.emplace(g.id, std::move(g));
177 std::vector<uint8_t> buf;
180 std::ofstream os(p, std::ios::binary | std::ios::trunc);
181 os.write((
const char*)buf.data(), (std::streamsize)buf.size());
195 if (!std::filesystem::exists(p))
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);
209 is.read((
char*)buf.data(), sz);
222 std::hash<std::string> H;
223 uint64_t
id = H(name);
225 id ^= 0x9E3779B97F4A7C15ull;
235 return g.participants.count(meUid) > 0;
241 return it ==
groups_.end() ? nullptr : &it->second;
249 case K::ChatGroupCreate:
251 if (!m.tableId || !m.threadId)
258 g.name = m.name.value_or(
"Group");
261 std::string ownerUid;
263 ownerUid =
identity_manager->uniqueForPeer(m.fromPeerId).value_or(
"Player");
264 g.ownerUniqueId = ownerUid;
267 g.participants = *m.participants;
271 groups_.emplace(g.id, std::move(g));
274 it->second.name = g.name;
275 it->second.participants = std::move(g.participants);
283 case K::ChatGroupUpdate:
285 if (!m.tableId || !m.threadId)
294 stub.
id = *m.threadId;
295 stub.name = m.name.value_or(
"Group");
297 stub.participants = *m.participants;
301 stub.ownerUniqueId =
identity_manager->uniqueForPeer(m.fromPeerId).value_or(
"Player");
303 groups_.emplace(stub.id, std::move(stub));
310 g->participants = *m.participants;
315 case K::ChatGroupDelete:
317 if (!m.tableId || !m.threadId)
324 if (
auto* g =
getGroup(*m.threadId))
327 const std::string reqOwner =
329 if (!g->ownerUniqueId.empty() && g->ownerUniqueId == reqOwner)
341 if (!m.tableId || !m.threadId || !m.ts || !m.name || !m.text)
347 std::string senderUid =
353 msg.senderUniqueId = senderUid;
354 msg.username = *m.name;
355 msg.content = *m.text;
356 msg.ts = (double)*m.ts;
362 stub.
id = *m.threadId;
363 stub.name =
"(pending?)";
364 groups_.emplace(stub.id, std::move(stub));
367 g->messages.push_back(std::move(
msg));
371 g->unread = std::min<uint32_t>(g->unread + 1, 999u);
382 const std::string& fromPeer,
383 const std::string& username,
384 const std::string& text,
390 msg.username = username;
410 stub.name =
"(pending?)";
411 groups_.emplace(groupId, std::move(stub));
415 g->messages.push_back(std::move(
msg));
419 g->unread = std::min<uint32_t>(g->unread + 1, 999u);
422 "LOCAL append gid=" + std::to_string(groupId) +
" user=" + username +
423 " text=\"" + text +
"\"");
438 nm->broadcastChatJson(j);
443 if (!targets.empty())
444 nm->sendChatJsonTo(targets, j);
457 nm->broadcastChatJson(j);
462 if (!targets.empty())
463 nm->sendChatJsonTo(targets, j);
475 auto it =
groups_.find(groupId);
479 const auto& g = it->second;
485 if (!targets.empty())
486 nm->sendChatJsonTo(targets, j);
495 auto it =
groups_.find(groupId);
499 const auto& parts = it->second.participants;
503 nm->broadcastChatJson(j);
507 if (!targets.empty())
508 nm->sendChatJsonTo(targets, j);
521 auto it =
groups_.find(groupId);
525 auto& g = it->second;
532 if (!g.ownerUniqueId.empty() && g.ownerUniqueId == meUid)
536 g.participants.erase(meUid);
541 if (!targets.empty())
542 nm->sendChatJsonTo(targets, j);
545 if (
activeGroupId_ == groupId && g.participants.count(meUid) == 0)
551 std::set<std::string> out;
556 for (
const auto& uid : participantUids)
567 const std::string& newUsername)
573 if (g.ownerUniqueId == uniqueId)
578 for (
auto& m : g.messages)
580 if (m.senderUniqueId == uniqueId)
581 m.username = newUsername;
609 ImGui::Begin(
"ChatWindow");
610 chatWindowFocused_ = ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows | ImGuiFocusedFlags_RootWindow);
612 const float leftWmin = 170.0f;
614 const float fullW = ImGui::GetContentRegionAvail().x;
615 const float fullH = ImGui::GetContentRegionAvail().y;
617 ImGui::BeginChild(
"Left", ImVec2(
leftWidth_, 0),
true, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize);
622 ImGui::InvisibleButton(
"##splitter", ImVec2(6, fullH));
623 if (ImGui::IsItemActive())
630 ImGui::BeginChild(
"Right", ImVec2(0, 0),
true, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize);
640 return ImVec4(v, v, v, 1.0f);
641 h = std::fmodf(h, 1.0f) * 6.0f;
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;
681 return ImVec4(r, g, b, 1.0f);
692 std::hash<std::string> H;
693 uint64_t hv = H(name) ^ 0x9E3779B97F4A7C15ull;
694 float hue = (float)((hv % 10000) / 10000.0f);
697 if (hue > 0.12f && hue < 0.18f)
702 const float sat = 0.65f;
703 const float val = 0.90f;
705 ImVec4 rgb =
HSVtoRGB(hue, sat, val);
706 ImU32 col = ImGui::GetColorU32(rgb);
715 ImGui::PushStyleColor(ImGuiCol_Text, col);
716 ImGui::TextUnformatted(name.c_str());
717 ImGui::PopStyleColor();
722 ImGui::TextWrapped(
"%s", text.c_str());
728 if (input.rfind(
"/roll", 0) != 0)
734 auto uname = nm->getMyUsername();
738 auto parseInt = [](
const std::string& s,
size_t& i) ->
int
742 while (i < s.size() && std::isdigit((
unsigned char)s[i]))
745 v = v * 10 + (s[i] -
'0');
752 while (i < input.size() && std::isspace((
unsigned char)input[i]))
755 int N = parseInt(input, i);
756 if (i >= input.size() || input[i] !=
'd' || N <= 0)
761 int M = parseInt(input, i);
766 if (i < input.size() && (input[i] ==
'+' || input[i] ==
'-'))
768 bool neg = (input[i] ==
'-');
770 int Z = parseInt(input, i);
775 std::mt19937 rng((uint32_t)std::random_device{}());
776 std::uniform_int_distribution<int> dist(1, std::max(1, M));
780 for (
int r = 0; r < N; ++r)
786 rolls += std::to_string(die);
790 const int modApplied = K;
791 const int finalTotal = total + modApplied;
793 const std::string text =
"rolled " + std::to_string(N) +
"d" + std::to_string(M) +
795 std::to_string(modApplied) +
")" +
796 " => [" + rolls +
"] = " + std::to_string(finalTotal);
798 const uint64_t ts = (uint64_t)
nowSec();
806 if (ImGui::Button(
"New Group"))
809 if (ImGui::Button(
"Delete Group"))
814 ImGui::BeginDisabled(!canEdit);
815 if (ImGui::Button(
"Edit Group"))
828 ImGui::EndDisabled();
831 ImGui::TextUnformatted(
"Chat Groups");
835 std::vector<uint64_t> order;
839 std::sort(order.begin(), order.end(), [&](uint64_t a, uint64_t b)
841 if (a == generalGroupId_) return true;
842 if (b == generalGroupId_) return false;
843 return groups_[a].name < groups_[b].name; });
845 for (
auto id : order)
850 ImGui::PushID((
int)
id);
851 if (ImGui::Selectable(g.name.c_str(), selected, ImGuiSelectableFlags_SpanAllColumns, ImVec2(0, 0)))
861 ImVec2 min = ImGui::GetItemRectMin();
862 ImVec2 max = ImGui::GetItemRectMax();
863 ImVec2 size(max.x - min.x, max.y - min.y);
867 std::snprintf(buf,
sizeof(buf),
"%u", g.unread);
869 std::snprintf(buf,
sizeof(buf),
"99+");
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);
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));
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);
893 ImGui::OpenPopup(
"CreateGroup");
900 ImGui::OpenPopup(
"DeleteGroup");
907 ImGui::OpenPopup(
"EditGroup");
915 const float footerRowH = ImGui::GetFrameHeightWithSpacing() * 2.0f;
917 ImVec2 avail = ImGui::GetContentRegionAvail();
918 ImGui::BeginChild(
"Messages", ImVec2(0, avail.y - footerRowH),
true, ImGuiWindowFlags_AlwaysVerticalScrollbar);
921 if (ImGui::GetScrollY() < ImGui::GetScrollMaxY() - 1.0f)
924 for (
auto& m : g->messages)
929 ImGui::SameLine(0.0f, 6.0f);
930 ImGui::TextUnformatted(
"-");
931 ImGui::SameLine(0.0f, 6.0f);
936 ImGui::PushTextWrapPos(0);
938 ImGui::PopTextWrapPos();
941 ImGui::SetScrollHereY(1.0f);
944 ImGui::SetScrollHereY(1.0f);
951 ImGui::BeginChild(
"Footer", ImVec2(0, 0),
false, ImGuiWindowFlags_AlwaysAutoResize);
955 ImGui::SetKeyboardFocusHere();
959 ImGuiInputTextFlags flags = ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_AllowTabInput;
960 if (ImGui::InputText(
"##chat_input",
input_.data(), (
int)
input_.size(), flags))
962 std::string text(
input_.data());
963 if (!text.empty() && g)
965 const uint64_t ts = (uint64_t)
nowSec();
967 auto uname = nm ? nm->getMyUsername() : std::string(
"me");
971 if (!text.empty() && text[0] ==
'/')
989 if (ImGui::Button(
"Send") && g)
991 std::string text(
input_.data());
994 const uint64_t ts = (uint64_t)
nowSec();
996 auto uname = nm ? nm->getMyUsername() : std::string(
"me");
1010 if (ImGui::Button(
"Go to bottom"))
1014 ImGui::EndDisabled();
1016 if (ImGui::Button(
"Roll Dice"))
1023 ImGui::OpenPopup(
"DiceRoller");
1111 if (ImGui::BeginPopupModal(
"CreateGroup",
nullptr, ImGuiWindowFlags_AlwaysAutoResize))
1113 ImGui::TextUnformatted(
"Group name (unique):");
1118 ImGui::TextUnformatted(
"Participants:");
1121 for (
auto& [pid, link] : nm->getPeers())
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))
1145 bool nameTaken =
false;
1147 if (!desired.empty())
1150 if (g.name == desired)
1158 ImGui::BeginDisabled(desired.empty() || nameTaken);
1159 if (ImGui::Button(
"Create"))
1169 if (!g.ownerUniqueId.empty())
1170 g.participants.insert(g.ownerUniqueId);
1183 ImGui::CloseCurrentPopup();
1185 ImGui::EndDisabled();
1188 ImGui::TextColored(ImVec4(1, 0.4f, 0.2f, 1),
"A group with that name already exists.");
1191 if (ImGui::Button(
"Cancel"))
1195 ImGui::CloseCurrentPopup();
1300 if (ImGui::BeginPopupModal(
"EditGroup",
nullptr, ImGuiWindowFlags_AlwaysAutoResize))
1305 ImGui::TextDisabled(
"Group not found.");
1306 if (ImGui::Button(
"Close"))
1307 ImGui::CloseCurrentPopup();
1315 const bool ownerOnly =
true;
1316 const bool canEdit = !ownerOnly || (!g->ownerUniqueId.empty() && g->ownerUniqueId == meUid);
1318 ImGui::TextColored(ImVec4(1, 0.5f, 0.3f, 1),
"Only the owner can edit this group.");
1321 ImGui::TextUnformatted(
"Group name:");
1322 ImGui::BeginDisabled(!canEdit);
1325 ImGui::EndDisabled();
1329 ImGui::TextUnformatted(
"Participants:");
1332 ImGui::BeginDisabled(!canEdit);
1333 for (
auto& [pid, link] : nm->getPeers())
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))
1354 ImGui::EndDisabled();
1358 bool nameTaken =
false;
1360 if (desired.empty() ==
false && desired != g->name)
1362 for (
auto& [
id, gg] :
groups_)
1363 if (gg.name == desired)
1370 ImGui::TextColored(ImVec4(1, 0.4f, 0.2f, 1),
"A group with that name already exists.");
1373 ImGui::BeginDisabled(!canEdit || desired.empty() || nameTaken);
1374 if (ImGui::Button(
"Update", ImVec2(120, 0)))
1389 ImGui::CloseCurrentPopup();
1391 ImGui::EndDisabled();
1394 if (ImGui::Button(
"Close", ImVec2(120, 0)))
1395 ImGui::CloseCurrentPopup();
1450 if (ImGui::BeginPopupModal(
"DeleteGroup",
nullptr, ImGuiWindowFlags_AlwaysAutoResize))
1452 ImGui::TextUnformatted(
"Manage groups you participate in (General cannot be deleted/left).");
1462 const std::string gmUid = nm->getGMId();
1463 iAmGM = (!gmUid.empty() && !meUid.empty() && gmUid == meUid);
1468 const uint64_t
id = it->first;
1482 const bool isOwner = (!g.ownerUniqueId.empty() && g.ownerUniqueId == meUid);
1483 const bool canDelete = isOwner;
1484 const bool canLeave = (iParticipate && !isOwner);
1486 ImGui::PushID((
int)
id);
1489 ImGui::TextUnformatted(g.name.c_str());
1493 ImGui::BeginDisabled(!canDelete);
1494 if (ImGui::Button(
"Delete"))
1507 ImGui::EndDisabled();
1512 ImGui::BeginDisabled(!canLeave);
1513 if (ImGui::Button(
"Leave"))
1527 ImGui::EndDisabled();
1533 ImGui::TextDisabled(
"(not a participant)");
1538 ImGui::TextDisabled(
"(owner)");
1543 ImGui::TextDisabled(
"(GM)");
1551 if (ImGui::Button(
"Close"))
1552 ImGui::CloseCurrentPopup();
1559 if (ImGui::BeginPopup(
"DiceRoller"))
1561 ImGui::TextUnformatted(
"Dice Roller");
1564 ImGui::InputInt(
"Number", &
diceN_);
1568 ImGui::InputInt(
"Modifier", &
diceMod_);
1572 if (ImGui::Button(
"d4"))
1575 if (ImGui::Button(
"d6"))
1578 if (ImGui::Button(
"d8"))
1581 if (ImGui::Button(
"d10"))
1584 if (ImGui::Button(
"d12"))
1587 if (ImGui::Button(
"d20"))
1591 if (ImGui::Button(
"Roll"))
1606 int N = std::max(1,
diceN_);
1610 std::string cmd =
"/roll " + std::to_string(N) +
"d" + std::to_string(M);
1612 cmd +=
"+" + std::to_string(K);
1614 cmd +=
"-" + std::to_string(-K);
1617 ImGui::CloseCurrentPopup();
1620 if (ImGui::Button(
"Cancel"))
1621 ImGui::CloseCurrentPopup();
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)
std::array< char, 512 > input_
ChatGroupModel * getGroup(uint64_t id)
void renderEditGroupPopup()
std::set< std::string > resolvePeerIdsForParticipants(const std::set< std::string > &participantUids) const
void renderCreateGroupPopup()
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 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)
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
std::unordered_map< std::string, ImU32 > nameColorCache_
std::string currentTableName_
bool isMeParticipantOf(const ChatGroupModel &g) const
void renderDeleteGroupPopup()
bool saveLog(std::vector< uint8_t > &buf) const
static ChatMessageModel::Kind classifyMessage(const std::string &s)
std::unordered_map< uint64_t, ChatGroupModel > groups_
static constexpr uint64_t generalGroupId_
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
void markGroupRead(uint64_t groupId)
void renderLeftPanel(float width)
std::weak_ptr< NetworkManager > network_
void applyReady(const msg::ReadyMessage &m)
void emitGroupDelete(uint64_t groupId)
static Logger & instance()
static fs::path getGameTablesPath()
static int deserializeInt(const std::vector< unsigned char > &buffer, size_t &offset)
static std::string deserializeString(const std::vector< unsigned char > &buffer, size_t &offset)
static void serializeUInt64(std::vector< unsigned char > &buffer, uint64_t value)
static void serializeString(std::vector< unsigned char > &buffer, const std::string &str)
static uint64_t deserializeUInt64(const std::vector< unsigned char > &buffer, size_t &offset)
static void serializeInt(std::vector< unsigned char > &buffer, int value)
Json makeChatMessage(uint64_t tableId, uint64_t groupId, uint64_t ts, const std::string &username, const std::string &text)
Json makeChatGroupUpdate(uint64_t tableId, uint64_t groupId, const std::string &name, const std::set< std::string > &participants)
Json makeChatGroupCreate(uint64_t tableId, uint64_t groupId, const std::string &name, const std::set< std::string > &participants)
Json makeChatGroupDelete(uint64_t tableId, uint64_t groupId)