fix(tell): strip stale tell state on tab activation
OnTabActivated clears the runtime tell state the game-side detour leaves on a tab (CurrentChannel tell target + partner label) when a DIFFERENT tab becomes the input surface, so a normal typed line can no longer route as a silent /tell to the old partner. Re-clicking the active tab and tabs carrying their own Tab.TellTarget binding (leg1) are preserved. All four activation paths route through it: Sidebar, TopTabBar, ChannelPopoutPool.TryOpen, and the MainWindow draw-seed. EnsureCurrentChannel becomes a pure derive-helper reached only via OnTabActivated. Adds the TellResetOnActivateStep self-test (step count 29 -> 30).
This commit is contained in:
@@ -405,6 +405,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
new SelfTests.SidebarGreetedGlyphStep(this),
|
||||
new SelfTests.SidebarSectionHeaderStep(this),
|
||||
new SelfTests.ScrollSnapDecisionStep(this),
|
||||
new SelfTests.TellResetOnActivateStep(),
|
||||
]);
|
||||
|
||||
// Re-surface the wizard for existing users when a major UX
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Game.Text.SeStringHandling;
|
||||
using Dalamud.Plugin.SelfTest;
|
||||
using HellionChat.Code;
|
||||
using HellionChat.GameFunctions.Types;
|
||||
using HellionChat.Util;
|
||||
|
||||
namespace HellionChat.SelfTests;
|
||||
|
||||
// F1: the activation strip. Drives the REAL OnTabActivated — the entry the
|
||||
// Sidebar/TopTabBar click handlers, the pop-out path and the Draw-seed all call
|
||||
// — with local probe tabs (Plugin.Config.Tabs is never touched). Asserts the
|
||||
// five contracts: strip-on-switch, no-strip-on-reclick (TR-4), leg1 preserve,
|
||||
// derive, and non-tell untouched.
|
||||
internal sealed class TellResetOnActivateStep : ISelfTestStep
|
||||
{
|
||||
public string Name => "Hellion Chat - Tell reset on tab activate";
|
||||
|
||||
public SelfTestStepResult RunStep()
|
||||
{
|
||||
var other = MakeSayTab();
|
||||
|
||||
// (a) switching ONTO a stale-tell tab with no Tab-level binding strips the
|
||||
// runtime tell state (target + partner label) and re-derives the channel.
|
||||
var stale = MakeStaleTellTab(boundTellTarget: false, withLabel: true);
|
||||
TabLifecycleHelpers.OnTabActivated(stale, other);
|
||||
if (stale.CurrentChannel.TellTarget is not null)
|
||||
{
|
||||
ImGui.Text("(a) stale tell target not cleared on switch");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
if (stale.CurrentChannel.Channel != InputChannel.Say)
|
||||
{
|
||||
ImGui.Text($"(a) channel not re-derived to Say, got {stale.CurrentChannel.Channel}");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
if (stale.CurrentChannel.Name.Count != 0)
|
||||
{
|
||||
ImGui.Text("(a) stale partner label not cleared");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
// (b) re-clicking the already-active tab (previous == tab) must NOT strip
|
||||
// a live game-tell conversation (TR-4 regression guard).
|
||||
var reclick = MakeStaleTellTab(boundTellTarget: false, withLabel: false);
|
||||
TabLifecycleHelpers.OnTabActivated(reclick, reclick);
|
||||
if (reclick.CurrentChannel.TellTarget is null)
|
||||
{
|
||||
ImGui.Text("(b) re-click wrongly stripped the active tell tab");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
if (reclick.CurrentChannel.Channel != InputChannel.Tell)
|
||||
{
|
||||
ImGui.Text("(b) re-click wrongly changed the active tab's channel");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
// (c) a tab whose own Tab.TellTarget is set is a real binding (leg1):
|
||||
// channel + runtime target survive a switch.
|
||||
var bound = MakeStaleTellTab(boundTellTarget: true, withLabel: false);
|
||||
TabLifecycleHelpers.OnTabActivated(bound, other);
|
||||
if (bound.CurrentChannel.TellTarget is null)
|
||||
{
|
||||
ImGui.Text("(c) bound tell tab wrongly stripped");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
if (bound.CurrentChannel.Channel != InputChannel.Tell)
|
||||
{
|
||||
ImGui.Text("(c) bound tell tab channel wrongly changed");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
// (d) an Invalid-channel tab just derives (pre-existing semantics).
|
||||
var invalid = MakeSayTab();
|
||||
TabLifecycleHelpers.OnTabActivated(invalid, other);
|
||||
if (invalid.CurrentChannel.Channel != InputChannel.Say)
|
||||
{
|
||||
ImGui.Text(
|
||||
$"(d) invalid-channel tab not derived, got {invalid.CurrentChannel.Channel}"
|
||||
);
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
// (e) a non-tell tab is left untouched. Seed it with runtime tell state
|
||||
// AND a label so a guard that wrongly fired on non-tell tabs would null
|
||||
// them — the channel re-derive alone could not mask that regression.
|
||||
var say = MakeSayTab();
|
||||
say.CurrentChannel.SetChannel(InputChannel.Say);
|
||||
say.CurrentChannel.TellTarget = new TellTarget("Untouched", 21, 0, TellReason.Direct);
|
||||
var sayLabel = new SeStringBuilder().AddText("Untouched@World").Build();
|
||||
say.CurrentChannel.Name = ChunkUtil
|
||||
.ToChunks(sayLabel, ChunkSource.Content, ChatType.Say)
|
||||
.ToList();
|
||||
TabLifecycleHelpers.OnTabActivated(say, other);
|
||||
if (say.CurrentChannel.Channel != InputChannel.Say)
|
||||
{
|
||||
ImGui.Text("(e) non-tell tab channel wrongly changed");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
if (say.CurrentChannel.TellTarget is null || say.CurrentChannel.Name.Count == 0)
|
||||
{
|
||||
ImGui.Text("(e) non-tell tab runtime state wrongly stripped");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
return SelfTestStepResult.Pass;
|
||||
}
|
||||
|
||||
// A tab carrying runtime tell state the way the game-side detour leaves it:
|
||||
// CurrentChannel.Channel == Tell with a resolvable CurrentChannel.TellTarget,
|
||||
// optionally with the partner-name label chunks. boundTellTarget controls
|
||||
// whether the Tab-level TellTarget marks it a real binding (leg1).
|
||||
private static Tab MakeStaleTellTab(bool boundTellTarget, bool withLabel)
|
||||
{
|
||||
var tab = new Tab
|
||||
{
|
||||
Name = "selftest-activate-tell",
|
||||
TellTarget = boundTellTarget
|
||||
? new TellTarget("Bound", 21, 0, TellReason.Direct)
|
||||
: TellTarget.Empty(),
|
||||
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
|
||||
{
|
||||
[ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
},
|
||||
};
|
||||
tab.CurrentChannel.SetChannel(InputChannel.Tell);
|
||||
tab.CurrentChannel.TellTarget = new TellTarget("Stale", 21, 0, TellReason.Direct);
|
||||
if (withLabel)
|
||||
{
|
||||
var ss = new SeStringBuilder().AddText("Stale@World").Build();
|
||||
tab.CurrentChannel.Name = ChunkUtil
|
||||
.ToChunks(ss, ChunkSource.Content, ChatType.Say)
|
||||
.ToList();
|
||||
}
|
||||
return tab;
|
||||
}
|
||||
|
||||
private static Tab MakeSayTab() =>
|
||||
new()
|
||||
{
|
||||
Name = "selftest-activate-say",
|
||||
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
|
||||
{
|
||||
[ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
},
|
||||
};
|
||||
|
||||
public void CleanUp() { }
|
||||
}
|
||||
@@ -247,8 +247,9 @@ internal sealed class Sidebar
|
||||
var rowHovered = ImGui.IsItemHovered();
|
||||
if (ImGui.IsItemClicked())
|
||||
{
|
||||
var previous = activeTab;
|
||||
activeTab = tab;
|
||||
TabLifecycleHelpers.EnsureCurrentChannel(tab);
|
||||
TabLifecycleHelpers.OnTabActivated(tab, previous);
|
||||
}
|
||||
|
||||
dl.DrawHoverSheen(
|
||||
|
||||
@@ -34,8 +34,9 @@ internal sealed class TopTabBar
|
||||
)
|
||||
)
|
||||
{
|
||||
var previous = activeTab;
|
||||
activeTab = tab;
|
||||
TabLifecycleHelpers.EnsureCurrentChannel(tab);
|
||||
TabLifecycleHelpers.OnTabActivated(tab, previous);
|
||||
}
|
||||
|
||||
TabContextMenu.Draw(tab, $"toptab_ctx_{i}", _pool);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using HellionChat.Util;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace HellionChat.Ui.Windows;
|
||||
@@ -40,6 +41,12 @@ internal sealed class ChannelPopoutPool
|
||||
|
||||
public bool TryOpen(Tab tab)
|
||||
{
|
||||
// A popped tab gets its own input bar, so strip stale tell state first —
|
||||
// otherwise a popped-out stale-tell tab would be a send surface that
|
||||
// bypasses the click-path activation strip. Previous = the main window's
|
||||
// active tab; popping the active tab itself must not strip (TR-4 guard).
|
||||
TabLifecycleHelpers.OnTabActivated(tab, Plugin.Instance.MainWindow?.ActiveTab);
|
||||
|
||||
var slot = _slots.TryReserve(tab.Identifier);
|
||||
if (slot < 0)
|
||||
{
|
||||
|
||||
@@ -156,7 +156,13 @@ internal sealed class MainWindow : Window
|
||||
// First-frame seed: the active tab defaults to the first persisted
|
||||
// tab so the message list isn't empty on a clean session.
|
||||
if (_activeTab is null && Plugin.Config.Tabs.Count > 0)
|
||||
_activeTab = Plugin.Config.Tabs[0];
|
||||
{
|
||||
var seeded = Plugin.Config.Tabs[0];
|
||||
_activeTab = seeded;
|
||||
// The seeded Tabs[0] is the likeliest legacy stale-tell carrier
|
||||
// (pre-coupling the detour wrote here); strip it like any activation.
|
||||
TabLifecycleHelpers.OnTabActivated(seeded, null);
|
||||
}
|
||||
|
||||
var statusHeight = Components.StatusBar.Height;
|
||||
|
||||
|
||||
@@ -17,8 +17,35 @@ internal static class TabLifecycleHelpers
|
||||
|
||||
public static bool ShouldStripOnSave(Tab t) => IsInUnpinnedPool(t);
|
||||
|
||||
// Shared by the click paths (Sidebar, TopTabBar) and the keybind tab-cycle
|
||||
// path so every entry point resolves a tab's channel identically (no drift).
|
||||
// Stale-tell strip + channel derive, run at every tab activation. When a
|
||||
// DIFFERENT tab becomes the input surface, drop any runtime tell state the
|
||||
// game-side detour left on it (the CurrentChannel tell target plus the
|
||||
// partner-name label) so a normal typed line cannot route as a silent /tell
|
||||
// to the old partner — the same privacy guard StripTellBindingOnPromote
|
||||
// applies on promote. Re-activating the already-active tab must NOT strip
|
||||
// (a live game-tell would lose its context, TR-4); a tab carrying its own
|
||||
// Tab.TellTarget is a real tell binding (leg1) and is left intact.
|
||||
internal static void OnTabActivated(Tab tab, Tab? previous)
|
||||
{
|
||||
if (
|
||||
!ReferenceEquals(tab, previous)
|
||||
&& tab.CurrentChannel.Channel == InputChannel.Tell
|
||||
&& tab.TellTarget?.IsSet() != true
|
||||
)
|
||||
{
|
||||
tab.CurrentChannel.SetChannel(InputChannel.Invalid);
|
||||
tab.CurrentChannel.TellTarget = null;
|
||||
tab.CurrentChannel.ResetTempChannel();
|
||||
// Label chunks carry the partner name after a game-side tell.
|
||||
tab.CurrentChannel.Name = [];
|
||||
}
|
||||
|
||||
EnsureCurrentChannel(tab);
|
||||
}
|
||||
|
||||
// Pure derive-helper: resolves a tab's input channel from its
|
||||
// SelectedChannels when none is set yet. Reached only via OnTabActivated
|
||||
// now, so the strip and the derive stay in lockstep at every entry.
|
||||
internal static void EnsureCurrentChannel(Tab tab)
|
||||
{
|
||||
if (tab.CurrentChannel.Channel != InputChannel.Invalid)
|
||||
|
||||
Reference in New Issue
Block a user