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.SidebarGreetedGlyphStep(this),
|
||||||
new SelfTests.SidebarSectionHeaderStep(this),
|
new SelfTests.SidebarSectionHeaderStep(this),
|
||||||
new SelfTests.ScrollSnapDecisionStep(this),
|
new SelfTests.ScrollSnapDecisionStep(this),
|
||||||
|
new SelfTests.TellResetOnActivateStep(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Re-surface the wizard for existing users when a major UX
|
// 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();
|
var rowHovered = ImGui.IsItemHovered();
|
||||||
if (ImGui.IsItemClicked())
|
if (ImGui.IsItemClicked())
|
||||||
{
|
{
|
||||||
|
var previous = activeTab;
|
||||||
activeTab = tab;
|
activeTab = tab;
|
||||||
TabLifecycleHelpers.EnsureCurrentChannel(tab);
|
TabLifecycleHelpers.OnTabActivated(tab, previous);
|
||||||
}
|
}
|
||||||
|
|
||||||
dl.DrawHoverSheen(
|
dl.DrawHoverSheen(
|
||||||
|
|||||||
@@ -34,8 +34,9 @@ internal sealed class TopTabBar
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
var previous = activeTab;
|
||||||
activeTab = tab;
|
activeTab = tab;
|
||||||
TabLifecycleHelpers.EnsureCurrentChannel(tab);
|
TabLifecycleHelpers.OnTabActivated(tab, previous);
|
||||||
}
|
}
|
||||||
|
|
||||||
TabContextMenu.Draw(tab, $"toptab_ctx_{i}", _pool);
|
TabContextMenu.Draw(tab, $"toptab_ctx_{i}", _pool);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using HellionChat.Util;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace HellionChat.Ui.Windows;
|
namespace HellionChat.Ui.Windows;
|
||||||
@@ -40,6 +41,12 @@ internal sealed class ChannelPopoutPool
|
|||||||
|
|
||||||
public bool TryOpen(Tab tab)
|
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);
|
var slot = _slots.TryReserve(tab.Identifier);
|
||||||
if (slot < 0)
|
if (slot < 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -156,7 +156,13 @@ internal sealed class MainWindow : Window
|
|||||||
// First-frame seed: the active tab defaults to the first persisted
|
// First-frame seed: the active tab defaults to the first persisted
|
||||||
// tab so the message list isn't empty on a clean session.
|
// tab so the message list isn't empty on a clean session.
|
||||||
if (_activeTab is null && Plugin.Config.Tabs.Count > 0)
|
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;
|
var statusHeight = Components.StatusBar.Height;
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,35 @@ internal static class TabLifecycleHelpers
|
|||||||
|
|
||||||
public static bool ShouldStripOnSave(Tab t) => IsInUnpinnedPool(t);
|
public static bool ShouldStripOnSave(Tab t) => IsInUnpinnedPool(t);
|
||||||
|
|
||||||
// Shared by the click paths (Sidebar, TopTabBar) and the keybind tab-cycle
|
// Stale-tell strip + channel derive, run at every tab activation. When a
|
||||||
// path so every entry point resolves a tab's channel identically (no drift).
|
// 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)
|
internal static void EnsureCurrentChannel(Tab tab)
|
||||||
{
|
{
|
||||||
if (tab.CurrentChannel.Channel != InputChannel.Invalid)
|
if (tab.CurrentChannel.Channel != InputChannel.Invalid)
|
||||||
|
|||||||
Reference in New Issue
Block a user