fd5f970a8b
Tester-Request from Jin (2026-05-03): TempTabs should be pinnable so a
key conversation partner survives a relog. Right-click a TempTab and
choose Pin Tab / Unpin Tab / Promote to permanent.
Pool semantics:
- AutoTellTabsLimit (15) still gates the auto-managed unpinned pool.
- Pinned TempTabs live in their own pool, hard-capped at 5.
- The 6th pin attempt fails with a notification; users can unpin first
or promote to permanent.
- Unpinning into a full unpinned pool drops the oldest unpinned (no
user friction).
Mechanics:
- Tab.IsPinned (default false); Tab.Clone() carries it.
- Migration v16 -> v17 (additive; existing tabs default to unpinned).
- Three strip-sites synchronised through TabLifecycleHelpers:
Plugin.cs load-time, Plugin.SaveConfig, Configuration.UpdateFrom.
- AutoTellTabsService:
* MaxPinnedTempTabs constant.
* F2.1 _activeTempTabCount counter retired — ActiveTempTabCount is
now Tabs.Count(predicate). Pin/Unpin/Promote transitions are
cold-path and don't need lock-free reads.
* DropOldestTempTab filters on IsInUnpinnedPool so pinned tabs are
never drop candidates.
* OnLogout strips only the unpinned pool; pinned popouts and the
active-tab switch behave correspondingly.
* TryPin / Unpin / PromoteToPermanent service methods.
- ChatLogWindow tab context menu: Pin / Unpin / Promote with disabled-
state at-cap tooltip + Promote tooltip explaining the channel-filter
side effect.
- HellionStrings (EN+DE) for menu labels, tooltips, the limit warning.
- AutoTellTabsLimit slider description now flags the separate pinned
pool so users aren't surprised by 18 tabs when the limit reads 15.
439 lines
14 KiB
C#
439 lines
14 KiB
C#
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;
|
|
|
|
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 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)
|
|
{
|
|
_plugin = plugin;
|
|
_messageManager = messageManager;
|
|
_store = store;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
_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)
|
|
{
|
|
// Diagnostics: helps detect regressions (FFXIV payload changes, new edge cases)
|
|
Plugin.LogProxy.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)
|
|
{
|
|
// Already routed via MessageManager pipeline
|
|
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 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
|
|
);
|
|
}
|
|
|
|
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, (ChatSource, ChatSource)>
|
|
{
|
|
[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
|
|
Plugin.LogProxy.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)
|
|
{
|
|
// 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)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (PinnedTempTabCount >= MaxPinnedTempTabs)
|
|
{
|
|
WrapperUtil.AddNotification(
|
|
string.Format(HellionStrings.PinTab_LimitReached, MaxPinnedTempTabs),
|
|
NotificationType.Warning
|
|
);
|
|
return false;
|
|
}
|
|
|
|
tab.IsPinned = true;
|
|
_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;
|
|
_plugin.SaveConfig();
|
|
}
|
|
|
|
internal void PromoteToPermanent(Tab tab)
|
|
{
|
|
if (!tab.IsTempTab)
|
|
{
|
|
return;
|
|
}
|
|
|
|
tab.IsTempTab = false;
|
|
tab.IsPinned = false;
|
|
tab.TellTarget = TellTarget.Empty();
|
|
_plugin.SaveConfig();
|
|
}
|
|
}
|