using System; using System.Collections.Generic; using System.Linq; using System.Threading; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Interface.ImGuiNotification; using HellionChat.Code; using HellionChat.GameFunctions.Types; using HellionChat.Resources; using HellionChat.Util; using Microsoft.Extensions.Logging; namespace HellionChat; // Auto-Tell-Tabs: spawns session-only tabs per /tell partner. // Subscribes to MessageManager.MessageProcessed and ClientState.Logout. internal sealed class AutoTellTabsService : IDisposable { private readonly Plugin _plugin; private readonly MessageManager _messageManager; private readonly MessageStore _store; private readonly ILogger _logger; private readonly object _tempTabsLock = new(); // Hard cap on pinned TempTabs so the sidebar doesn't inflate over years // of usage. Separate pool from AutoTellTabsLimit (15) — pinned tabs live // in their own bucket. A configurable cap is a vault-backlog anchor for // a later cycle if tester feedback demands it. internal const int MaxPinnedTempTabs = 5; private bool _initialized; internal AutoTellTabsService( Plugin plugin, MessageManager messageManager, MessageStore store, ILogger logger ) { _plugin = plugin; _messageManager = messageManager; _store = store; _logger = logger; } // Derived from the tab list on read. Pin/Unpin/Promote/Logout simply // mutate IsPinned or remove tabs — the count adapts automatically. // Replaces the F2.1 Interlocked counter because the new pin-state // transitions are cold-path and don't need lock-free reads. internal int ActiveTempTabCount => Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInUnpinnedPool); internal int PinnedTempTabCount => Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInPinnedPool); internal void Initialize() { if (_initialized) { return; } // Pinned tabs come out of the JSON with TellTarget set but // CurrentChannel reset (NonSerialized). Without re-seeding, the chat // input has no tell-target on the active pinned tab, and the // game-side channel hook only repaints CurrentChannel once the user // triggers a /tell or channel switch. RehydratePinnedTabs(); _messageManager.MessageProcessed += HandleTell; Plugin.ClientState.Logout += OnLogout; _initialized = true; } private void RehydratePinnedTabs() { var pinned = Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInPinnedPool); _logger.LogDebug($"[Pin] Rehydrate scan: {pinned} pinned tab(s) found"); foreach (var tab in Plugin.Config.Tabs) { if (!TabLifecycleHelpers.IsInPinnedPool(tab)) continue; if (tab.TellTarget is null || !tab.TellTarget.IsSet()) { _logger.LogWarning( $"[Pin] Pinned tab '{tab.Name}' has no usable TellTarget " + $"(Name={tab.TellTarget?.Name ?? ""} World={tab.TellTarget?.World ?? 0}). " + "Chat input on this tab will be empty until the partner sends a tell or you /tell manually." ); continue; } tab.Channel ??= InputChannel.Tell; tab.CurrentChannel.Channel = InputChannel.Tell; tab.CurrentChannel.TellTarget = tab.TellTarget.Clone(); // MessageList is NonSerialized so pinned tabs come back empty. // Preload the same history window the spawn path uses so the user // sees the recent conversation, not a blank tab. PreloadHistory(tab, tab.TellTarget.Name, tab.TellTarget.World, Guid.Empty); _logger.LogDebug( $"[Pin] Rehydrated '{tab.Name}' -> Tell target {tab.TellTarget.Name}@{tab.TellTarget.World}" ); } } 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) { // Diagnostics: helps detect regressions (FFXIV payload changes, new edge cases) _logger.LogWarning( $"[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) { // Already routed via MessageManager pipeline. Repair the // tell-target if the fallback hit a pinned tab whose // TellTarget didn't survive a previous round-trip — keeps // FindTempTab fast on the next message. if ( existing.IsPinned && (existing.TellTarget is null || !existing.TellTarget.IsSet()) ) { existing.TellTarget = new TellTarget( partner.Value.Name, partner.Value.World, 0, TellReason.Direct ); _plugin.SaveConfig(); } 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) { // Sender is the partner; check chunks first, then raw SeString as fallback var fromSender = ChunkUtil.TryGetPlayerPayload(message.Sender) ?? ChunkUtil.TryGetPlayerPayload(message.SenderSource); if (fromSender != null) { return (fromSender.PlayerName, fromSender.World.RowId); } return null; } // Outgoing tell: check content first, then channels's TellTarget as 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 static Tab? FindTempTab(string name, uint world) { var byTarget = Plugin.Config.Tabs.FirstOrDefault(t => t.IsTempTab && t.TellTarget != null && string.Equals(t.TellTarget.Name, name, StringComparison.OrdinalIgnoreCase) && t.TellTarget.World == world ); if (byTarget != null) return byTarget; // Fallback: match by tab name. Pinned tabs are named via // FormatTabName(player, world) at spawn time, so the name is a // stable secondary key when TellTarget didn't survive a save/load // (older configs from a renamed pin, malformed migrations, etc.). var expectedName = FormatTabName(name, world); return Plugin.Config.Tabs.FirstOrDefault(t => t.IsTempTab && string.Equals(t.Name, expectedName, StringComparison.OrdinalIgnoreCase) ); } internal void DropOldestTempTab() { // Pinned tabs live in their own bucket (MaxPinnedTempTabs) and are // never drop candidates. They leave the bucket only via Unpin or // PromoteToPermanent. var victim = Plugin .Config.Tabs.Select((tab, idx) => (Tab: tab, Index: idx)) .Where(t => TabLifecycleHelpers.IsInUnpinnedPool(t.Tab)) .OrderByDescending(t => t.Tab.IsGreeted) .ThenBy(t => t.Tab.LastActivity) .FirstOrDefault(); if (victim.Tab == null) { return; } // Clean up pop-out window if tab is popped out 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 active tab to avoid silent switch when tab is dropped 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 history: chronological order with current message already persisted PreloadHistory(tab, partner.Name, partner.World, currentMessage.Id); tab.AddMessage(currentMessage, unread: true); // Open as pop-out if configured (set before Tabs.Add for next render-tick) 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}"; } // Fallback if world lookup misses (rare; only for unseen worlds) 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: current message is already in store and would eat a preload slot 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; leave tab empty to avoid orphaned "history loaded" marker return; } // History is oldest-first; add in order for chronological display foreach (var message in historicMessages) { tab.Messages.AddPrune(message, MessageManager.MessageDisplayLimit); } // Separator between history and live tell (sorts after history but before current) tab.Messages.AddPrune( MakeSystemMarker(HellionStrings.AutoTellTabs_HistorySeparator), MessageManager.MessageDisplayLimit ); } catch (Exception ex) { // Non-fatal: tab still spawns with visible error notice instead of silent history loss _logger.LogError(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) { // Guard against frame-race: sidebar might render a tab already removed by LRU or logout if (!Plugin.Config.Tabs.Contains(tab)) { return; } tab.IsGreeted = greeted; } } private void OnLogout(int type, int code) { lock (_tempTabsLock) { // Pinned TempTabs must survive char-switch — that's the whole point // of pinning. Only unpinned ones get stripped. var lastIndex = _plugin.LastTab; var lastIndexValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count; var currentWasUnpinnedTempTab = lastIndexValid && TabLifecycleHelpers.IsInUnpinnedPool(Plugin.Config.Tabs[lastIndex]); var poppedTempTabIds = Plugin .Config.Tabs.Where(t => TabLifecycleHelpers.IsInUnpinnedPool(t) && 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(TabLifecycleHelpers.IsInUnpinnedPool); // Force switch to tab 0 if active tab was an unpinned temp tab or // index is now out of range. Pinned tabs survive — no switch needed. var stillValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count; if (currentWasUnpinnedTempTab || !stillValid) { _plugin.WantedTab = 0; } } } internal bool TryPin(Tab tab) { if (!tab.IsTempTab || tab.IsPinned) { _logger.LogDebug( $"[Pin] TryPin skipped: IsTempTab={tab.IsTempTab} IsPinned={tab.IsPinned}" ); return false; } if (PinnedTempTabCount >= MaxPinnedTempTabs) { WrapperUtil.AddNotification( string.Format(HellionStrings.PinTab_LimitReached, MaxPinnedTempTabs), NotificationType.Warning ); return false; } tab.IsPinned = true; _logger.LogDebug( $"[Pin] Pinned tab '{tab.Name}' target={tab.TellTarget?.Name}@{tab.TellTarget?.World}" ); _plugin.SaveConfig(); return true; } internal void Unpin(Tab tab) { if (!tab.IsPinned) { return; } // If the unpinned pool is already full, dropping the oldest before // flipping the flag avoids counting the just-unpinned tab as a drop // candidate. if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit) { DropOldestTempTab(); } tab.IsPinned = false; _logger.LogDebug("[Pin] Unpinned tab '{TabName}'", tab.Name); _plugin.SaveConfig(); } internal void PromoteToPermanent(Tab tab) { if (!tab.IsTempTab) { return; } tab.IsTempTab = false; tab.IsPinned = false; tab.TellTarget = TellTarget.Empty(); _logger.LogDebug($"[Pin] Promoted tab '{tab.Name}' to permanent (tell-binding dropped)"); _plugin.SaveConfig(); } }