fix(tabs): rehydrate pinned TempTab tell-targets after reload
Smoke-test (Jin's scenario) surfaced two coupled bugs in v1.4.7 pin persistence: 1. The chat input couldn't send to the pinned partner after a reload. Tab.CurrentChannel is NonSerialized, so it came back as a fresh UsedChannel with TellTarget=null even though tab.TellTarget (the persisted twin) was intact. The game-side channel hook only repaints CurrentChannel on a /tell or channel switch, so the pinned tab sat there mute until the user manually re-bounced the channel. 2. An incoming tell from the pinned partner spawned a *second* TempTab instead of routing into the existing pinned one. The Name+World lookup in FindTempTab was vulnerable to any round-trip nuance on tab.TellTarget — the fallback path now matches by tab name, which FormatTabName pins at spawn time. Fix: - AutoTellTabsService.Initialize now calls RehydratePinnedTabs() after the Phase-2 wiring lands, seeding tab.CurrentChannel.TellTarget + Channel from the persisted tab.TellTarget. Channel is also defaulted to InputChannel.Tell on the tab record so the chat-input bar paints Tell mode immediately on first selection. - FindTempTab gained a Name-based fallback for the case where the primary TellTarget lookup misses (e.g. a pinned tab whose TellTarget didn't round-trip cleanly through an old save). - HandleTell self-heals: when the fallback matches a pinned tab with a missing TellTarget, the tab is repaired from the live partner data and persisted, so subsequent messages take the fast path. Build-suite coverage was attempted (PinnedTabJsonRoundtripTests) but Tab + TellTarget are both Dalamud-coupled — Newtonsoft's reflection walk loads Dalamud.dll which isn't available in the xUnit AppDomain (documented in feedback_dalamud_test_isolation). Verification stays on the ingame smoke path.
This commit is contained in:
@@ -52,11 +52,33 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
return;
|
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;
|
_messageManager.MessageProcessed += HandleTell;
|
||||||
Plugin.ClientState.Logout += OnLogout;
|
Plugin.ClientState.Logout += OnLogout;
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void RehydratePinnedTabs()
|
||||||
|
{
|
||||||
|
foreach (var tab in Plugin.Config.Tabs)
|
||||||
|
{
|
||||||
|
if (!TabLifecycleHelpers.IsInPinnedPool(tab))
|
||||||
|
continue;
|
||||||
|
if (tab.TellTarget is null || !tab.TellTarget.IsSet())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
tab.Channel ??= InputChannel.Tell;
|
||||||
|
tab.CurrentChannel.Channel = InputChannel.Tell;
|
||||||
|
tab.CurrentChannel.TellTarget = tab.TellTarget.Clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (!_initialized)
|
if (!_initialized)
|
||||||
@@ -102,7 +124,23 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
var existing = FindTempTab(partner.Value.Name, partner.Value.World);
|
var existing = FindTempTab(partner.Value.Name, partner.Value.World);
|
||||||
if (existing != null)
|
if (existing != null)
|
||||||
{
|
{
|
||||||
// Already routed via MessageManager pipeline
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,14 +190,25 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Tab? FindTempTab(string name, uint world)
|
private static Tab? FindTempTab(string name, uint world)
|
||||||
{
|
{
|
||||||
return Plugin.Config.Tabs.FirstOrDefault(t =>
|
var byTarget = Plugin.Config.Tabs.FirstOrDefault(t =>
|
||||||
t.IsTempTab
|
t.IsTempTab
|
||||||
&& t.TellTarget != null
|
&& t.TellTarget != null
|
||||||
&& string.Equals(t.TellTarget.Name, name, StringComparison.OrdinalIgnoreCase)
|
&& string.Equals(t.TellTarget.Name, name, StringComparison.OrdinalIgnoreCase)
|
||||||
&& t.TellTarget.World == world
|
&& 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()
|
internal void DropOldestTempTab()
|
||||||
|
|||||||
Reference in New Issue
Block a user