diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index a596c959..153324b6 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -55,6 +55,24 @@ enum class QuestGiverStatus : uint8_t { REWARD = 10 // ? (yellow) }; +/** + * A single contact list entry (friend, ignore, or mute). + */ +struct ContactEntry { + uint64_t guid = 0; + std::string name; + std::string note; + uint32_t flags = 0; // 0x1=friend, 0x2=ignore, 0x4=mute + uint8_t status = 0; // 0=offline, 1=online, 2=AFK, 3=DND + uint32_t areaId = 0; + uint32_t level = 0; + uint32_t classId = 0; + + bool isFriend() const { return (flags & 0x1) != 0; } + bool isIgnored() const { return (flags & 0x2) != 0; } + bool isOnline() const { return status != 0; } +}; + /** * World connection state */ @@ -800,6 +818,7 @@ public: void leaveGroup(); bool isInGroup() const { return !partyData.isEmpty(); } const GroupListData& getPartyData() const { return partyData; } + const std::vector& getContacts() const { return contacts_; } bool hasPendingGroupInvite() const { return pendingGroupInvite; } const std::string& getPendingInviterName() const { return pendingInviterName; } @@ -1662,11 +1681,12 @@ private: std::unordered_map gameObjectInfoCache_; std::unordered_set pendingGameObjectQueries_; - // ---- Friend list cache ---- + // ---- Friend/contact list cache ---- std::unordered_map friendsCache; // name -> guid std::unordered_set friendGuids_; // all known friend GUIDs (for name backfill) uint32_t lastContactListMask_ = 0; uint32_t lastContactListCount_ = 0; + std::vector contacts_; // structured contact list (friends + ignores) // ---- World state and faction initialization snapshots ---- uint32_t worldStateMapId_ = 0; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 1496ea28..15e098e7 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -64,6 +64,7 @@ private: bool showChatWindow = true; bool showNameplates_ = true; // V key toggles nameplates bool showPlayerInfo = false; + bool showSocialFrame_ = false; // O key toggles social/friends list bool showGuildRoster_ = false; std::string selectedGuildMember_; bool showGuildNoteEdit_ = false; @@ -219,6 +220,7 @@ private: void renderSharedQuestPopup(game::GameHandler& gameHandler); void renderItemTextWindow(game::GameHandler& gameHandler); void renderBuffBar(game::GameHandler& gameHandler); + void renderSocialFrame(game::GameHandler& gameHandler); void renderLootWindow(game::GameHandler& gameHandler); void renderGossipWindow(game::GameHandler& gameHandler); void renderQuestDetailsWindow(game::GameHandler& gameHandler); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 4ee45df0..6e6e8d54 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -483,6 +483,7 @@ void GameHandler::disconnect() { playerNameCache.clear(); pendingNameQueries.clear(); friendGuids_.clear(); + contacts_.clear(); transportAttachments_.clear(); serverUpdatedTransportGuids_.clear(); requiresWarden_ = false; @@ -16425,6 +16426,11 @@ void GameHandler::handleFriendList(network::Packet& packet) { if (rem() < 1) return; uint8_t count = packet.readUInt8(); LOG_INFO("SMSG_FRIEND_LIST: ", (int)count, " entries"); + + // Rebuild friend contacts (keep ignores from previous contact_ entries) + contacts_.erase(std::remove_if(contacts_.begin(), contacts_.end(), + [](const ContactEntry& e){ return e.isFriend(); }), contacts_.end()); + for (uint8_t i = 0; i < count && rem() >= 9; ++i) { uint64_t guid = packet.readUInt64(); uint8_t status = packet.readUInt8(); @@ -16434,18 +16440,28 @@ void GameHandler::handleFriendList(network::Packet& packet) { level = packet.readUInt32(); classId = packet.readUInt32(); } - (void)area; (void)level; (void)classId; // Track as a friend GUID; resolve name via name query friendGuids_.insert(guid); auto nit = playerNameCache.find(guid); + std::string name; if (nit != playerNameCache.end()) { - friendsCache[nit->second] = guid; - LOG_INFO(" Friend: ", nit->second, " status=", (int)status); + name = nit->second; + friendsCache[name] = guid; + LOG_INFO(" Friend: ", name, " status=", (int)status); } else { LOG_INFO(" Friend guid=0x", std::hex, guid, std::dec, " status=", (int)status, " (name pending)"); queryPlayerName(guid); } + ContactEntry entry; + entry.guid = guid; + entry.name = name; + entry.flags = 0x1; // friend + entry.status = status; + entry.areaId = area; + entry.level = level; + entry.classId = classId; + contacts_.push_back(std::move(entry)); } } @@ -16469,19 +16485,23 @@ void GameHandler::handleContactList(network::Packet& packet) { } lastContactListMask_ = packet.readUInt32(); lastContactListCount_ = packet.readUInt32(); + contacts_.clear(); for (uint32_t i = 0; i < lastContactListCount_ && rem() >= 8; ++i) { uint64_t guid = packet.readUInt64(); if (rem() < 4) break; uint32_t flags = packet.readUInt32(); std::string note = packet.readString(); // may be empty - (void)note; + uint8_t status = 0; + uint32_t areaId = 0; + uint32_t level = 0; + uint32_t classId = 0; if (flags & 0x1) { // SOCIAL_FLAG_FRIEND if (rem() < 1) break; - uint8_t status = packet.readUInt8(); + status = packet.readUInt8(); if (status != 0 && rem() >= 12) { - packet.readUInt32(); // area - packet.readUInt32(); // level - packet.readUInt32(); // class + areaId = packet.readUInt32(); + level = packet.readUInt32(); + classId = packet.readUInt32(); } friendGuids_.insert(guid); auto nit = playerNameCache.find(guid); @@ -16492,6 +16512,17 @@ void GameHandler::handleContactList(network::Packet& packet) { } } // ignore / mute entries: no additional fields beyond guid+flags+note + ContactEntry entry; + entry.guid = guid; + entry.flags = flags; + entry.note = std::move(note); + entry.status = status; + entry.areaId = areaId; + entry.level = level; + entry.classId = classId; + auto nit = playerNameCache.find(guid); + if (nit != playerNameCache.end()) entry.name = nit->second; + contacts_.push_back(std::move(entry)); } LOG_INFO("SMSG_CONTACT_LIST: mask=", lastContactListMask_, " count=", lastContactListCount_); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index a48e2353..1a12493a 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5530,21 +5530,20 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { if (!ImGui::GetIO().WantCaptureKeyboard && ImGui::IsKeyPressed(ImGuiKey_O)) { showGuildRoster_ = !showGuildRoster_; if (showGuildRoster_) { + // Open friends tab directly if not in guild if (!gameHandler.isInGuild()) { - gameHandler.addLocalChatMessage(game::MessageChatData{ - game::ChatType::SYSTEM, game::ChatLanguage::UNIVERSAL, 0, "", 0, "", "You are not in a guild.", "", 0}); - showGuildRoster_ = false; - return; - } - // Re-query guild name if we have guildId but no name yet - if (gameHandler.getGuildName().empty()) { - const auto* ch = gameHandler.getActiveCharacter(); - if (ch && ch->hasGuild()) { - gameHandler.queryGuildInfo(ch->guildId); + guildRosterTab_ = 2; // Friends tab + } else { + // Re-query guild name if we have guildId but no name yet + if (gameHandler.getGuildName().empty()) { + const auto* ch = gameHandler.getActiveCharacter(); + if (ch && ch->hasGuild()) { + gameHandler.queryGuildInfo(ch->guildId); + } } + gameHandler.requestGuildRoster(); + gameHandler.requestGuildInfo(); } - gameHandler.requestGuildRoster(); - gameHandler.requestGuildInfo(); } } @@ -5595,7 +5594,7 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 375, screenH / 2 - 250), ImGuiCond_Once); ImGui::SetNextWindowSize(ImVec2(750, 500), ImGuiCond_Once); - std::string title = gameHandler.isInGuild() ? (gameHandler.getGuildName() + " - Guild") : "Guild"; + std::string title = gameHandler.isInGuild() ? (gameHandler.getGuildName() + " - Social") : "Social"; bool open = showGuildRoster_; if (ImGui::Begin(title.c_str(), &open, ImGuiWindowFlags_NoCollapse)) { // Tab bar: Roster | Guild Info @@ -5899,6 +5898,57 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::EndTabItem(); } + // ---- Friends tab ---- + if (ImGui::BeginTabItem("Friends")) { + guildRosterTab_ = 2; + const auto& contacts = gameHandler.getContacts(); + + // Filter to friends only + int friendCount = 0; + for (const auto& c : contacts) { + if (!c.isFriend()) continue; + ++friendCount; + + // Status dot + ImU32 dotColor = c.isOnline() + ? IM_COL32(80, 200, 80, 255) + : IM_COL32(120, 120, 120, 255); + ImVec2 cursor = ImGui::GetCursorScreenPos(); + ImGui::GetWindowDrawList()->AddCircleFilled( + ImVec2(cursor.x + 6.0f, cursor.y + 8.0f), 5.0f, dotColor); + ImGui::Dummy(ImVec2(14.0f, 0.0f)); + ImGui::SameLine(); + + // Name + const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str(); + ImVec4 nameCol = c.isOnline() + ? ImVec4(1.0f, 1.0f, 1.0f, 1.0f) + : ImVec4(0.55f, 0.55f, 0.55f, 1.0f); + ImGui::TextColored(nameCol, "%s", displayName); + + // Level and status on same line (right-aligned) + if (c.isOnline()) { + ImGui::SameLine(); + const char* statusLabel = + (c.status == 2) ? "(AFK)" : + (c.status == 3) ? "(DND)" : ""; + if (c.level > 0) { + ImGui::TextDisabled("Lv %u %s", c.level, statusLabel); + } else if (*statusLabel) { + ImGui::TextDisabled("%s", statusLabel); + } + } + } + + if (friendCount == 0) { + ImGui::TextDisabled("No friends online."); + } + + ImGui::Separator(); + ImGui::TextDisabled("Right-click a player's name in chat to add friends."); + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); } }