using System; using System.Collections.Generic; using System.Linq; using HellionChat.Code; using HellionChat.GameFunctions.Types; using HellionChat.Resources; using HellionChat.Util; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; namespace HellionChat; // Hellion Chat — Auto-Tell-Tabs. // // Spawns a session-only tab per /tell partner so a club greeter can track // multiple parallel conversations without losing context. Subscribes to // MessageManager.MessageProcessed for live tells and to ClientState.Logout // for the cleanup pass; everything else hangs off these two entry points. // // See spec: Hellion Chat Auto-Tell-Tabs Spec (Obsidian vault). internal sealed class AutoTellTabsService : IDisposable { private readonly Plugin _plugin; private readonly MessageManager _messageManager; private readonly MessageStore _store; private readonly object _tempTabsLock = new(); private bool _initialized; internal AutoTellTabsService(Plugin plugin, MessageManager messageManager, MessageStore store) { _plugin = plugin; _messageManager = messageManager; _store = store; } internal int ActiveTempTabCount { get { lock (_tempTabsLock) { return Plugin.Config.Tabs.Count(t => t.IsTempTab); } } } internal void Initialize() { if (_initialized) { return; } _messageManager.MessageProcessed += HandleTell; Plugin.ClientState.Logout += OnLogout; _initialized = true; } public void Dispose() { if (!_initialized) { return; } Plugin.ClientState.Logout -= OnLogout; _messageManager.MessageProcessed -= HandleTell; _initialized = false; } internal void HandleTell(Message message) { if (!Plugin.Config.EnableAutoTellTabs) { return; } if (message.Code.Type != ChatType.TellIncoming && message.Code.Type != ChatType.TellOutgoing) { return; } var partner = ExtractTellPartner(message); if (partner == null) { // Real message without a player payload — e.g. GM tells, which // we deliberately skip. The diagnostics make future regressions // (FFXIV changing tell payload shape, new edge cases) findable // without having to crank up debug logging at the source. Plugin.Log.Warning( $"[AutoTellTabs] Could not extract tell partner. type={message.Code.Type}, " + $"senderChunks={message.Sender.Count}, contentChunks={message.Content.Count}, " + $"senderSourcePayloads={message.SenderSource?.Payloads?.Count ?? 0}, " + $"contentSourcePayloads={message.ContentSource?.Payloads?.Count ?? 0}"); return; } lock (_tempTabsLock) { var existing = FindTempTab(partner.Value.Name, partner.Value.World); if (existing != null) { // Tab already exists; Tab.Matches has already routed this // message via the MessageManager pipeline (see Task 2 sender // filter). return; } if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit) { DropOldestTempTab(); } SpawnTempTab(partner.Value, message); } } private (string Name, uint World)? ExtractTellPartner(Message message) { if (message.Code.Type == ChatType.TellIncoming) { // Incoming tell: the sender is the conversation partner. The // PlayerPayload normally rides on a chunk's Link slot, but for // some tell types FFXIV only puts it in the raw SeString — // fall back to that before giving up. var fromSender = ChunkUtil.TryGetPlayerPayload(message.Sender) ?? ChunkUtil.TryGetPlayerPayload(message.SenderSource); if (fromSender != null) { return (fromSender.PlayerName, fromSender.World.RowId); } return null; } // Outgoing tell: the local player is the sender, the partner shows // up either as a payload in the content (for tells typed via the // Chat 2 input bar) or as the channel's tracked tell target (set by // the SetContextTellTarget game hook). Same SeString fallback. var fromContent = ChunkUtil.TryGetPlayerPayload(message.Content) ?? ChunkUtil.TryGetPlayerPayload(message.ContentSource) ?? ChunkUtil.TryGetPlayerPayload(message.Sender) ?? ChunkUtil.TryGetPlayerPayload(message.SenderSource); if (fromContent != null) { return (fromContent.PlayerName, fromContent.World.RowId); } var current = _plugin.CurrentTab.CurrentChannel.TellTarget ?? _plugin.CurrentTab.CurrentChannel.TempTellTarget; if (current != null && current.IsSet()) { return (current.Name, current.World); } return null; } private Tab? FindTempTab(string name, uint world) { return Plugin.Config.Tabs.FirstOrDefault(t => t.IsTempTab && t.TellTarget != null && string.Equals(t.TellTarget.Name, name, StringComparison.OrdinalIgnoreCase) && t.TellTarget.World == world); } private void DropOldestTempTab() { // Greeted tabs are dropped before un-greeted ones (the user said // "I'm done with that conversation"), and within each bucket we // pick the oldest LastActivity. This protects active conversations // and unfinished greetings while still freeing up a slot. var victim = Plugin.Config.Tabs .Select((tab, idx) => (Tab: tab, Index: idx)) .Where(t => t.Tab.IsTempTab) .OrderByDescending(t => t.Tab.IsGreeted) .ThenBy(t => t.Tab.LastActivity) .FirstOrDefault(); if (victim.Tab == null) { return; } // v0.6.1 — if the victim is currently popped out, tear down the // matching Popout window first. Otherwise the window stays in // PopOutWindows + WindowSystem and renders empty / re-spawns on the // next AddPopOutsToDraw tick. Latent since pop-outs were introduced; // becomes visible with AutoTellTabsOpenAsPopout where dropping a // popped tab is now a routine code path. if (victim.Tab.PopOut) { var popout = _plugin.ChatLogWindow.ActivePopouts .FirstOrDefault(p => p.TabIdentifier == victim.Tab.Identifier); if (popout != null) { popout.IsOpen = false; } } Plugin.Config.Tabs.RemoveAt(victim.Index); // Re-anchor the active tab so the user does not silently end up on // a different conversation when their tab gets dropped or shifted. if (victim.Index <= _plugin.LastTab) { _plugin.WantedTab = 0; } } private void SpawnTempTab((string Name, uint World) partner, Message currentMessage) { var tab = BuildTempTab(partner.Name, partner.World); // Preload first so the tab opens with chronological history above // the current message — and so a slow DB query never causes a // visible "empty tab, then history pops in" effect on screen. // The current message is already persisted in the store by the // time MessageProcessed fires (see MessageManager.cs: UpsertMessage // runs before the event), so we have to exclude it explicitly to // avoid the separator landing below the live tell. PreloadHistory(tab, partner.Name, partner.World, currentMessage.Id); tab.AddMessage(currentMessage, unread: true); // Hellion Chat v0.6.1 — opt-in: open new /tell tabs directly as a // pop-out window. Set BEFORE Tabs.Add so the next render-tick's // AddPopOutsToDraw() sees PopOut=true and spawns the Popout window // alongside the tab going into the list. No SaveConfig() because // auto-tell tabs are IsTempTab (session-only, never persisted). if (Plugin.Config.AutoTellTabsOpenAsPopout) { tab.PopOut = true; } Plugin.Config.Tabs.Add(tab); } private static Tab BuildTempTab(string playerName, uint worldRowId) { return new Tab { Name = FormatTabName(playerName, worldRowId), IsTempTab = true, AllSenderMessages = true, TellTarget = new TellTarget(playerName, worldRowId, 0, TellReason.Direct), Channel = InputChannel.Tell, DisplayTimestamp = true, UnreadMode = UnreadMode.Unseen, HideWhenInactive = false, SelectedChannels = new Dictionary { [ChatType.TellIncoming] = (ChatSourceExt.All, ChatSourceExt.All), [ChatType.TellOutgoing] = (ChatSourceExt.All, ChatSourceExt.All), }, }; } private static string FormatTabName(string playerName, uint worldRowId) { if (Sheets.WorldSheet.TryGetRow(worldRowId, out var worldRow)) { return $"{playerName}@{worldRow.Name}"; } // World sheet lookup miss is rare (only for FFXIV worlds Dalamud has // not yet seen). Fall back to the raw RowId so the user still has a // unique, readable label. return $"{playerName}@World{worldRowId}"; } private void PreloadHistory(Tab tab, string senderName, uint senderWorld, Guid currentMessageId) { var preloadCount = Plugin.Config.AutoTellTabsHistoryPreload; if (preloadCount <= 0) { return; } try { // Pull one extra row because the live tell that triggered this // spawn is already in the store and would otherwise eat one of // the user's preload-budget slots. var history = _store.GetTellHistoryWithSender( _messageManager.CurrentContentId, senderName, senderWorld, preloadCount + 1); var historicMessages = history .Where(m => m.Id != currentMessageId) .Take(preloadCount) .ToList(); if (historicMessages.Count == 0) { // No prior tells with this player — leave the tab to start // empty so the user does not see a "history loaded" marker // sitting alone above the very first message. return; } // The history list is already oldest-first, so a plain AddPrune // loop produces the chronological order the user expects to see // when the tab opens. foreach (var message in historicMessages) { tab.Messages.AddPrune(message, MessageManager.MessageDisplayLimit); } // Visible separator between the loaded history and the live // tell that triggered this spawn. Goes in last so it sorts // after the historical messages but before the current one. tab.Messages.AddPrune( MakeSystemMarker(HellionStrings.AutoTellTabs_HistorySeparator), MessageManager.MessageDisplayLimit); } catch (Exception ex) { // Non-fatal: the tab still spawns, but the user gets a visible // notice instead of silently missing history. The error logs // once with full stack trace for diagnosis. Plugin.Log.Error(ex, "[AutoTellTabs] History preload failed"); tab.Messages.AddPrune( MakeSystemMarker(HellionStrings.AutoTellTabs_HistoryLoadError), MessageManager.MessageDisplayLimit); } } private static Message MakeSystemMarker(string text) { var seString = new SeStringBuilder().AddText(text).Build(); var chunks = ChunkUtil.ToChunks(seString, ChunkSource.Content, ChatType.System).ToList(); var code = new ChatCode((XivChatType)ChatType.System, 0, 0); return Message.FakeMessage(chunks, code); } internal void MarkGreeted(Tab tab) { SetGreeted(tab, true); } internal void UnmarkGreeted(Tab tab) { SetGreeted(tab, false); } internal bool IsGreeted(Tab tab) { return tab.IsGreeted; } private void SetGreeted(Tab tab, bool greeted) { if (tab == null) { return; } lock (_tempTabsLock) { // Frame-race guard (E5): the sidebar might still render a tab // that has already been removed by LRU drop or logout cleanup. // Silently skip the toggle so we don't mutate stale state. if (!Plugin.Config.Tabs.Contains(tab)) { return; } tab.IsGreeted = greeted; } } private void OnLogout(int type, int code) { lock (_tempTabsLock) { // Snapshot whether the active tab is about to be removed, BEFORE // we mutate the list — index lookups would lie to us afterwards. var lastIndex = _plugin.LastTab; var lastIndexValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count; var currentWasTempTab = lastIndexValid && Plugin.Config.Tabs[lastIndex].IsTempTab; // v0.6.1 — symmetric to DropOldestTempTab cleanup: tear down any // popped-out temp tab windows before removing the tabs themselves, // otherwise PopOutWindows + WindowSystem keep ghost entries until // the next plugin reload. Especially relevant once Auto-Pop-Out is // enabled — every logout would otherwise leak as many ghosts as // there were active /tell pop-outs. var poppedTempTabIds = Plugin.Config.Tabs .Where(t => t.IsTempTab && t.PopOut) .Select(t => t.Identifier) .ToList(); if (poppedTempTabIds.Count > 0) { var poppedSet = poppedTempTabIds.ToHashSet(); foreach (var popout in _plugin.ChatLogWindow.ActivePopouts .Where(p => poppedSet.Contains(p.TabIdentifier)) .ToList()) { popout.IsOpen = false; } } Plugin.Config.Tabs.RemoveAll(t => t.IsTempTab); // Force a switch to tab 0 if the active tab was a temp tab OR // if drops before the active index pushed LastTab out of range. // Otherwise the user keeps their current persistent tab. var stillValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count; if (currentWasTempTab || !stillValid) { _plugin.WantedTab = 0; } } } }