2144eedd76
The live tell that triggers an Auto-Tell-Tab spawn is already in the message store by the time MessageProcessed fires, because MessageManager calls Store.UpsertMessage on line 266 before invoking the event on line 277. PreloadHistory therefore picked up the live tell as the youngest historic message and the separator landed below it instead of above. Pass the live message id through SpawnTempTab into PreloadHistory and filter it out of the result. Pull one extra row so a successful exclude does not cost the user a preload-budget slot.
371 lines
13 KiB
C#
371 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using ChatTwo.Code;
|
|
using ChatTwo.GameFunctions.Types;
|
|
using ChatTwo.Resources;
|
|
using ChatTwo.Util;
|
|
using Dalamud.Game.Text;
|
|
using Dalamud.Game.Text.SeStringHandling;
|
|
|
|
namespace ChatTwo;
|
|
|
|
// 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;
|
|
}
|
|
|
|
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);
|
|
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}";
|
|
}
|
|
// 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;
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|