Merge LastTab-decoupling fix + unread-badge restore (1.8.6) into v1.8.x track
This commit is contained in:
@@ -260,13 +260,17 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
// is rebuilt — Tab.PopOut still flips on/off, the visible window
|
||||
// disappears once the new pool comes online.
|
||||
|
||||
var dropped = victim.Tab;
|
||||
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;
|
||||
}
|
||||
// Re-anchor the UI selection if it pointed at the dropped tab. This runs on
|
||||
// the PendingMessage worker thread and the repair mutates the re-seeded
|
||||
// tab's channel via OnTabActivated, so marshal it onto the framework thread
|
||||
// to serialize with Draw (reference_dalamud_framework_thread) — otherwise a
|
||||
// half-applied strip could race the input bar's send-routing read.
|
||||
Plugin.Framework.RunOnFrameworkThread(() =>
|
||||
_plugin.MainWindow?.ResetActiveTabIfRemoved(dropped)
|
||||
);
|
||||
}
|
||||
|
||||
private void SpawnTempTab((string Name, uint World) partner, Message currentMessage)
|
||||
@@ -417,11 +421,7 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
{
|
||||
// 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 active = _plugin.MainWindow?.ActiveTab;
|
||||
|
||||
var poppedTempTabIds = Plugin
|
||||
.Config.Tabs.Where(t => TabLifecycleHelpers.IsInUnpinnedPool(t) && t.PopOut)
|
||||
@@ -432,12 +432,13 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
|
||||
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)
|
||||
// Re-anchor the UI selection if the active tab was one of the stripped
|
||||
// unpinned temp tabs (reference predicate, not an index). Logout is a
|
||||
// framework-thread event, so this is already serialized with Draw — no
|
||||
// marshalling needed here, unlike the worker-thread eviction path.
|
||||
if (active is { } a && TabLifecycleHelpers.IsInUnpinnedPool(a))
|
||||
{
|
||||
_plugin.WantedTab = 0;
|
||||
_plugin.MainWindow?.ResetActiveTabIfRemoved(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Dalamud.NET.Sdk/15.0.0">
|
||||
<PropertyGroup>
|
||||
<!-- Independent versioning; see yaml changelog for upstream Chat 2 base -->
|
||||
<Version>1.8.5</Version>
|
||||
<Version>1.8.6</Version>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- Use lock file to pin exact versions -->
|
||||
|
||||
@@ -331,15 +331,15 @@ internal class MessageManager : IAsyncDisposable
|
||||
if (Plugin.Config.DatabaseBattleMessages || !message.Code.IsBattle())
|
||||
Store.UpsertMessage(message);
|
||||
|
||||
var currentMatches = Plugin.CurrentTab.Matches(message);
|
||||
// Snapshot the active tab and whether it shows this message ONCE, so the
|
||||
// whole loop sees a consistent value (the getter is a cross-thread read of
|
||||
// MainWindow.ActiveTab).
|
||||
var currentTab = Plugin.CurrentTab;
|
||||
var currentTabMatches = currentTab.Matches(message);
|
||||
foreach (var tab in Plugin.Config.Tabs)
|
||||
{
|
||||
var unread = !(
|
||||
tab.UnreadMode == UnreadMode.Unseen && Plugin.CurrentTab != tab && currentMatches
|
||||
);
|
||||
|
||||
if (tab.Matches(message))
|
||||
tab.AddMessage(message, unread);
|
||||
tab.AddMessage(message, ShouldCountUnread(tab, currentTab, currentTabMatches));
|
||||
}
|
||||
|
||||
// Deliberate O(2n): the sound pick re-walks the tab list so the selection
|
||||
@@ -385,6 +385,20 @@ internal class MessageManager : IAsyncDisposable
|
||||
// match wins" semantics live here via the running 'picked is null' guard,
|
||||
// keeping a message matching several background tabs from stacking sounds.
|
||||
// TEST-MIRROR: ../_Helpers/TabSoundDecision.cs
|
||||
// Unseen ("count only what you haven't seen") suppresses unread on an inactive
|
||||
// tab when the active tab ALSO shows this message — you already saw it in the
|
||||
// tab you're looking at (1.5.6 / upstream ChatTwo behavior). Pre-F2 the "active
|
||||
// tab" was wrongly pinned to Tabs[0], so this fired against the wrong tab; F2
|
||||
// recoupled CurrentTab to the REAL active tab, so currentTabMatches is now
|
||||
// measured against the tab you actually see. All -> always counts; None ->
|
||||
// counts here and is gated out at the display layer. Pure + SelfTest-able.
|
||||
internal static bool ShouldCountUnread(Tab tab, Tab currentTab, bool currentTabMatches) =>
|
||||
!(
|
||||
tab.UnreadMode == UnreadMode.Unseen
|
||||
&& !ReferenceEquals(currentTab, tab)
|
||||
&& currentTabMatches
|
||||
);
|
||||
|
||||
internal static uint? SelectNotificationSound(
|
||||
IEnumerable<Tab> tabs,
|
||||
Tab currentTab,
|
||||
|
||||
+11
-11
@@ -182,17 +182,12 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
|
||||
internal DateTime GameStarted { get; }
|
||||
|
||||
// Tab management lives here rather than in ChatLogWindow for access reasons.
|
||||
internal int LastTab { get; set; }
|
||||
internal int? WantedTab { get; set; }
|
||||
internal Tab CurrentTab
|
||||
{
|
||||
get
|
||||
{
|
||||
var i = LastTab;
|
||||
return i > -1 && i < Config.Tabs.Count ? Config.Tabs[i] : new Tab();
|
||||
}
|
||||
}
|
||||
// Couples "current tab" to the real UI selection. The chat hooks are
|
||||
// installed before MainWindow is Phase-1 resolved, so the null-conditional
|
||||
// fallback to Tabs[0] is load-bearing — it keeps the pre-coupling behavior
|
||||
// in that early window rather than being merely defensive.
|
||||
internal Tab CurrentTab =>
|
||||
MainWindow?.ActiveTab ?? (Config.Tabs.Count > 0 ? Config.Tabs[0] : new Tab());
|
||||
|
||||
public Plugin()
|
||||
{
|
||||
@@ -405,6 +400,11 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
new SelfTests.SidebarGreetedGlyphStep(this),
|
||||
new SelfTests.SidebarSectionHeaderStep(this),
|
||||
new SelfTests.ScrollSnapDecisionStep(this),
|
||||
new SelfTests.TellResetOnActivateStep(),
|
||||
new SelfTests.CurrentTabCouplingStep(this),
|
||||
new SelfTests.SidebarUnreadDotStep(this),
|
||||
new SelfTests.UnreadDecisionStep(),
|
||||
new SelfTests.CurrentTabGuidedStep(this),
|
||||
]);
|
||||
|
||||
// Re-surface the wizard for existing users when a major UX
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Plugin.SelfTest;
|
||||
|
||||
namespace HellionChat.SelfTests;
|
||||
|
||||
// F2: CurrentTab is coupled to MainWindow.ActiveTab (no longer the fixed index-0
|
||||
// Tabs lookup). Asserts ReferenceEquals between the two, with false-green
|
||||
// defenses: (1) empty-config exercises the getter's fallback; (2) null ActiveTab
|
||||
// opens the window so the Draw-seed sets it and retries via Waiting (bounded so a
|
||||
// never-drawn window cannot hang a batch); (3) a victim tab at index 0 makes a
|
||||
// regressed index-0 getter return the victim (!= ActiveTab) and fail. Also checks
|
||||
// the ResetActiveTabIfRemoved reference no-op branch.
|
||||
internal sealed class CurrentTabCouplingStep : ISelfTestStep
|
||||
{
|
||||
private readonly Plugin _plugin;
|
||||
private bool _forcedOpen;
|
||||
private int _waitFrames;
|
||||
|
||||
public CurrentTabCouplingStep(Plugin plugin)
|
||||
{
|
||||
_plugin = plugin;
|
||||
}
|
||||
|
||||
public string Name => "Hellion Chat - CurrentTab couples to active tab";
|
||||
|
||||
public SelfTestStepResult RunStep()
|
||||
{
|
||||
// Empty-config edge: actually exercise the getter's empty-fallback (it must
|
||||
// return a fresh Tab, not null/throw) rather than an unconditional pass.
|
||||
if (Plugin.Config.Tabs.Count == 0)
|
||||
{
|
||||
if (_plugin.CurrentTab is null)
|
||||
{
|
||||
ImGui.Text("Empty-config getter returned null instead of a fallback Tab.");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
ImGui.Text("No tabs configured; getter returns the empty-fallback Tab.");
|
||||
return SelfTestStepResult.Pass;
|
||||
}
|
||||
|
||||
// /xlperf usually runs without the window drawn, so ActiveTab can be null
|
||||
// on the first pass. Open the window so the Draw-seed sets it, retry next
|
||||
// frame, and assert unconditionally once it is non-null. Bounded so a
|
||||
// never-drawn window cannot hang a batch run.
|
||||
if (_plugin.MainWindow.ActiveTab is null)
|
||||
{
|
||||
if (!_plugin.MainWindow.IsOpen)
|
||||
{
|
||||
_plugin.MainWindow.Toggle();
|
||||
_forcedOpen = true;
|
||||
}
|
||||
|
||||
if (++_waitFrames > 300)
|
||||
{
|
||||
RestoreWindow();
|
||||
ImGui.Text(
|
||||
"MainWindow never drew a seed within 300 frames; coupling not asserted."
|
||||
);
|
||||
return SelfTestStepResult.Pass;
|
||||
}
|
||||
|
||||
ImGui.Text("Opening window so the draw-seed can set ActiveTab; retrying...");
|
||||
return SelfTestStepResult.Waiting;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Insert a victim at index 0: a regressed index-0 getter would return
|
||||
// THIS instead of ActiveTab, so ReferenceEquals would catch it.
|
||||
var victim = new Tab { Name = "selftest-coupling-victim" };
|
||||
Plugin.Config.Tabs.Insert(0, victim);
|
||||
try
|
||||
{
|
||||
if (!ReferenceEquals(_plugin.CurrentTab, _plugin.MainWindow.ActiveTab))
|
||||
{
|
||||
ImGui.Text("CurrentTab is not the same reference as ActiveTab");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
if (ReferenceEquals(_plugin.CurrentTab, victim))
|
||||
{
|
||||
ImGui.Text("CurrentTab returned the index-0 victim (getter still index-based)");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
// Reference no-op: resetting against a tab that is NOT the active
|
||||
// one must leave the active reference untouched.
|
||||
var activeBefore = _plugin.MainWindow.ActiveTab;
|
||||
_plugin.MainWindow.ResetActiveTabIfRemoved(victim);
|
||||
if (!ReferenceEquals(_plugin.MainWindow.ActiveTab, activeBefore))
|
||||
{
|
||||
ImGui.Text("ResetActiveTabIfRemoved changed the active tab on a non-match");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
return SelfTestStepResult.Pass;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Plugin.Config.Tabs.Remove(victim);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
RestoreWindow();
|
||||
}
|
||||
}
|
||||
|
||||
private void RestoreWindow()
|
||||
{
|
||||
if (_forcedOpen && _plugin.MainWindow.IsOpen)
|
||||
_plugin.MainWindow.Toggle();
|
||||
_forcedOpen = false;
|
||||
}
|
||||
|
||||
public void CleanUp()
|
||||
{
|
||||
RestoreWindow();
|
||||
_waitFrames = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Plugin.SelfTest;
|
||||
using HellionChat.Code;
|
||||
using HellionChat.GameFunctions.Types;
|
||||
|
||||
namespace HellionChat.SelfTests;
|
||||
|
||||
// F2 (guided): interactive, fires NO synthetic probes. Shows the full measured
|
||||
// state every frame so a result is observable, not a guess, and walks the user
|
||||
// through the real switch-away-and-back flow. It verifies the PRIVACY-relevant
|
||||
// effect, keyed on the tab type:
|
||||
// - a NORMAL tab carrying a game-side tell must lose its RUNTIME target
|
||||
// (CurrentChannel.TellTarget) on switch-away-and-back (the F1 strip), so a
|
||||
// typed line can't /tell the old partner;
|
||||
// - a BOUND auto-tell tab keeps its partner by design (leg1) — its binding is
|
||||
// Tab.TellTarget and is deliberately untouched by the strip.
|
||||
// The channel label is intentionally NOT asserted: a tell tab re-derives back to
|
||||
// Tell after the strip (spec TR-7); only the target matters for privacy.
|
||||
internal sealed class CurrentTabGuidedStep : ISelfTestStep
|
||||
{
|
||||
private readonly Plugin _plugin;
|
||||
|
||||
// 0 = waiting for a tell; 1 = tell seen, waiting to switch AWAY; 2 = switched
|
||||
// away, waiting to come BACK to the tracked tab.
|
||||
private int _phase;
|
||||
private Tab? _tellTab;
|
||||
private bool _wasBound;
|
||||
private string _seenPartner = "";
|
||||
|
||||
public CurrentTabGuidedStep(Plugin plugin)
|
||||
{
|
||||
_plugin = plugin;
|
||||
}
|
||||
|
||||
public string Name => "Hellion Chat - Tell target cleared on tab switch (guided)";
|
||||
|
||||
public SelfTestStepResult RunStep()
|
||||
{
|
||||
var active = _plugin.CurrentTab;
|
||||
var cc = active.CurrentChannel;
|
||||
var bound = active.TellTarget?.IsSet() == true;
|
||||
var runtime = cc.TellTarget?.IsSet() == true;
|
||||
|
||||
// Live diagnostics every frame — a result is never a guess.
|
||||
ImGui.Text($"Active tab : {active.Name}");
|
||||
ImGui.Text($"Channel : {cc.Channel}");
|
||||
ImGui.Text($"Runtime target : {DescribeTarget(cc.TellTarget)}");
|
||||
ImGui.Text($"Tab-bound (leg1): {(bound ? $"yes -> {active.TellTarget!.Name}" : "no")}");
|
||||
if (_tellTab is not null)
|
||||
ImGui.Text(
|
||||
$"Tracking '{_tellTab.Name}' (bound: {_wasBound}, partner: {_seenPartner})"
|
||||
);
|
||||
ImGui.Separator();
|
||||
|
||||
if (ImGui.Button("Skip##guided-tellflow"))
|
||||
{
|
||||
ImGui.Text("Skipped by user — not verified.");
|
||||
return SelfTestStepResult.Pass;
|
||||
}
|
||||
|
||||
// Restart cleanly if the tracked tab is evicted mid-flow.
|
||||
if (_tellTab is not null && !Plugin.Config.Tabs.Contains(_tellTab))
|
||||
{
|
||||
ImGui.Text(">> Tracked tab was removed; restarting.");
|
||||
Reset();
|
||||
}
|
||||
|
||||
if (_phase == 0)
|
||||
{
|
||||
ImGui.Text(">> Step 1: get a tab into Tell — /tell from a normal tab (stay on it),");
|
||||
ImGui.Text(" or open an auto-tell tab. Watch the lines above update.");
|
||||
if (cc.Channel == InputChannel.Tell && (runtime || bound))
|
||||
{
|
||||
_tellTab = active;
|
||||
_wasBound = bound;
|
||||
_seenPartner = bound ? active.TellTarget!.Name : cc.TellTarget!.Name;
|
||||
_phase = 1;
|
||||
}
|
||||
|
||||
return SelfTestStepResult.Waiting;
|
||||
}
|
||||
|
||||
if (_phase == 1)
|
||||
{
|
||||
ImGui.Text(">> Step 2: now click AWAY to a different tab.");
|
||||
if (!ReferenceEquals(active, _tellTab))
|
||||
_phase = 2;
|
||||
|
||||
return SelfTestStepResult.Waiting;
|
||||
}
|
||||
|
||||
// _phase == 2: switched away; wait to come BACK, then check the target.
|
||||
ImGui.Text($">> Step 3: now click BACK onto '{_tellTab!.Name}'.");
|
||||
if (!ReferenceEquals(active, _tellTab))
|
||||
return SelfTestStepResult.Waiting;
|
||||
|
||||
if (_wasBound)
|
||||
{
|
||||
// leg1: the binding lives on Tab.TellTarget and must survive the strip.
|
||||
if (_tellTab.TellTarget?.IsSet() == true)
|
||||
{
|
||||
ImGui.Text(
|
||||
"PASS: bound auto-tell tab kept its partner (leg1 — the conversation stays)."
|
||||
);
|
||||
return SelfTestStepResult.Pass;
|
||||
}
|
||||
|
||||
ImGui.Text(
|
||||
$"FAIL: bound tab LOST partner '{_seenPartner}' — leg1 was wrongly stripped."
|
||||
);
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
// non-bound: the stale RUNTIME target must be gone (the privacy strip).
|
||||
if (_tellTab.CurrentChannel.TellTarget?.IsSet() != true)
|
||||
{
|
||||
ImGui.Text(
|
||||
$"PASS: stale partner '{_seenPartner}' cleared — a typed line won't /tell them."
|
||||
);
|
||||
return SelfTestStepResult.Pass;
|
||||
}
|
||||
|
||||
ImGui.Text(
|
||||
"FAIL: stale runtime partner still bound after switch-away-and-back — privacy leak."
|
||||
);
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
private static string DescribeTarget(TellTarget? t) =>
|
||||
t?.IsSet() == true ? $"{t.Name} (World {t.World})" : "none";
|
||||
|
||||
private void Reset()
|
||||
{
|
||||
_phase = 0;
|
||||
_tellTab = null;
|
||||
_wasBound = false;
|
||||
_seenPartner = "";
|
||||
}
|
||||
|
||||
public void CleanUp() => Reset();
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Plugin.SelfTest;
|
||||
using HellionChat.Code;
|
||||
|
||||
namespace HellionChat.SelfTests;
|
||||
|
||||
// F3: the unread dot the v1.8.x sidebar rebuild dropped. Drives the REAL
|
||||
// Sidebar.Draw (render precedent: SidebarGreetedGlyphStep) with a probe tab that
|
||||
// is inactive and carries Unread>0, then reads the render-observability counter
|
||||
// so a regressed/absent dot fails. Asserts: dot drawn for an inactive Unseen tab;
|
||||
// NOT drawn for UnreadMode.None. Uses a local one-tab list so the count is
|
||||
// unambiguous; restores SidebarWidth in finally.
|
||||
internal sealed class SidebarUnreadDotStep : ISelfTestStep
|
||||
{
|
||||
private readonly Plugin _plugin;
|
||||
|
||||
public SidebarUnreadDotStep(Plugin plugin) => _plugin = plugin;
|
||||
|
||||
public string Name => "Hellion Chat - Sidebar unread dot";
|
||||
|
||||
public SelfTestStepResult RunStep()
|
||||
{
|
||||
var sidebar = _plugin.MainWindow.GetSidebarForSelfTest();
|
||||
if (sidebar is null)
|
||||
{
|
||||
ImGui.Text("Sidebar null");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
var probe = new Tab
|
||||
{
|
||||
Name = "Unread Probe@SelfTest",
|
||||
UnreadMode = UnreadMode.Unseen,
|
||||
Unread = 3,
|
||||
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
|
||||
{
|
||||
[ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
},
|
||||
};
|
||||
var list = new List<Tab> { probe };
|
||||
Tab? active = null; // probe is NOT the active tab
|
||||
var width = (float)Plugin.Config.SidebarAutoSwitchThresholdPx + 100f; // expanded
|
||||
var savedWidth = Plugin.Config.SidebarWidth;
|
||||
try
|
||||
{
|
||||
Plugin.Config.SidebarWidth = 220;
|
||||
|
||||
// (a) an inactive Unseen tab with Unread>0 draws exactly one dot
|
||||
// (the one-tab list makes the expected count unambiguous).
|
||||
sidebar.Draw(width, list, ref active);
|
||||
if (sidebar.LastRenderedUnreadDotCount != 1)
|
||||
{
|
||||
ImGui.Text(
|
||||
$"Expected exactly 1 unread dot, got {sidebar.LastRenderedUnreadDotCount}"
|
||||
);
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
// (b) UnreadMode.None opts the tab out — no dot.
|
||||
probe.UnreadMode = UnreadMode.None;
|
||||
sidebar.Draw(width, list, ref active);
|
||||
if (sidebar.LastRenderedUnreadDotCount != 0)
|
||||
{
|
||||
ImGui.Text("Unread dot drawn for an UnreadMode.None tab");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Plugin.Config.SidebarWidth = savedWidth;
|
||||
}
|
||||
|
||||
return SelfTestStepResult.Pass;
|
||||
}
|
||||
|
||||
public void CleanUp() { }
|
||||
}
|
||||
@@ -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() { }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Plugin.SelfTest;
|
||||
|
||||
namespace HellionChat.SelfTests;
|
||||
|
||||
// F3: the unread decision (MessageManager.ShouldCountUnread). Unseen suppresses
|
||||
// unread on an inactive tab only when the active tab ALSO shows the message (you
|
||||
// saw it there) — 1.5.6/upstream semantics, now measured against the REAL active
|
||||
// tab thanks to F2. Asserts the truth table: suppressed when active tab also
|
||||
// matches; counts when it does not (the Carla/Jin case); All always counts; None
|
||||
// counts at the increment layer (the display gate hides it).
|
||||
internal sealed class UnreadDecisionStep : ISelfTestStep
|
||||
{
|
||||
public string Name => "Hellion Chat - Unread decision (per active tab)";
|
||||
|
||||
public SelfTestStepResult RunStep()
|
||||
{
|
||||
var active = new Tab { Name = "active", UnreadMode = UnreadMode.Unseen };
|
||||
var inactive = new Tab { Name = "inactive", UnreadMode = UnreadMode.Unseen };
|
||||
|
||||
// (a) inactive Unseen tab + the active tab ALSO shows the message
|
||||
// (currentTabMatches=true) => suppressed (you saw it in the active tab).
|
||||
if (MessageManager.ShouldCountUnread(inactive, active, currentTabMatches: true))
|
||||
{
|
||||
ImGui.Text("(a) inactive Unseen tab must be suppressed when active tab also shows it");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
// (b) inactive Unseen tab + the active tab does NOT show the message
|
||||
// (currentTabMatches=false) => counts (badge). The Carla/Jin case.
|
||||
if (!MessageManager.ShouldCountUnread(inactive, active, currentTabMatches: false))
|
||||
{
|
||||
ImGui.Text("(b) inactive Unseen tab must count when the active tab does not show it");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
// (c) the active tab itself counts here (current==tab short-circuits the
|
||||
// suppression); the draw loop zeroes it so no dot is ever shown.
|
||||
if (!MessageManager.ShouldCountUnread(active, active, currentTabMatches: true))
|
||||
{
|
||||
ImGui.Text("(c) active tab should count at the increment layer (draw loop zeroes it)");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
// (d) All-mode always counts, regardless of currentTabMatches.
|
||||
var all = new Tab { Name = "all", UnreadMode = UnreadMode.All };
|
||||
if (!MessageManager.ShouldCountUnread(all, active, currentTabMatches: true))
|
||||
{
|
||||
ImGui.Text("(d) All-mode tab should always count unread");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
// (e) None counts at the increment layer (the None opt-out lives in the
|
||||
// display gate, not here).
|
||||
var none = new Tab { Name = "none", UnreadMode = UnreadMode.None };
|
||||
if (!MessageManager.ShouldCountUnread(none, active, currentTabMatches: true))
|
||||
{
|
||||
ImGui.Text("(e) None should count at the increment layer (display gates it)");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
return SelfTestStepResult.Pass;
|
||||
}
|
||||
|
||||
public void CleanUp() { }
|
||||
}
|
||||
@@ -35,6 +35,7 @@ internal sealed class Sidebar
|
||||
// Incremented ONLY in the real glyph branch in DrawRow; reset at Draw start.
|
||||
// The SelfTest reads it after driving the real Draw — no dead service roundtrip.
|
||||
internal int LastRenderedGreetedGlyphCount;
|
||||
internal int LastRenderedUnreadDotCount;
|
||||
|
||||
// B3-4 render observability: section headers actually drawn this frame.
|
||||
// Incremented only in the real header branch; reset at Draw start.
|
||||
@@ -105,6 +106,7 @@ internal sealed class Sidebar
|
||||
public void Draw(float windowWidth, IList<Tab> tabs, ref Tab? activeTab)
|
||||
{
|
||||
LastRenderedGreetedGlyphCount = 0;
|
||||
LastRenderedUnreadDotCount = 0;
|
||||
LastDrawnSectionHeaderCount = 0;
|
||||
|
||||
if (!_fonts.FontsReady)
|
||||
@@ -124,6 +126,7 @@ internal sealed class Sidebar
|
||||
var textAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary);
|
||||
var mutedAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextMuted);
|
||||
var dimAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextDim);
|
||||
var dangerAbgr = ColourUtil.RgbaToAbgr(theme.Colors.StatusDanger);
|
||||
var dl = ImGui.GetWindowDrawList();
|
||||
|
||||
// B3-4 sectioned render order (1.5.6 parity): persistent → pinned
|
||||
@@ -154,7 +157,18 @@ internal sealed class Sidebar
|
||||
unpinnedHeaderRendered = true;
|
||||
}
|
||||
|
||||
DrawRow(tab, i, expanded, accentRgba, textAbgr, mutedAbgr, dimAbgr, dl, ref activeTab);
|
||||
DrawRow(
|
||||
tab,
|
||||
i,
|
||||
expanded,
|
||||
accentRgba,
|
||||
textAbgr,
|
||||
mutedAbgr,
|
||||
dimAbgr,
|
||||
dangerAbgr,
|
||||
dl,
|
||||
ref activeTab
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +218,7 @@ internal sealed class Sidebar
|
||||
uint textAbgr,
|
||||
uint mutedAbgr,
|
||||
uint dimAbgr,
|
||||
uint dangerAbgr,
|
||||
ImDrawListPtr dl,
|
||||
ref Tab? activeTab
|
||||
)
|
||||
@@ -247,8 +262,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(
|
||||
@@ -275,7 +291,20 @@ internal sealed class Sidebar
|
||||
// Icon and label shift right by the greeted slot when it is shown.
|
||||
var contentX = showGreeted ? GreetedHitWidth : 0f;
|
||||
using (_fonts.FontAwesome.Push())
|
||||
dl.AddText(origin + new Vector2(10f + contentX, 8f), iconColor, icon.ToIconString());
|
||||
{
|
||||
var iconStr = icon.ToIconString();
|
||||
dl.AddText(origin + new Vector2(10f + contentX, 8f), iconColor, iconStr);
|
||||
|
||||
// 1.5.6-parity unread dot, top-right of the icon. The active tab is
|
||||
// zeroed every frame (MainWindow.Draw), so the dot never shows on the
|
||||
// tab you're viewing; UnreadMode.None opts a tab out entirely.
|
||||
if (!isCurrentTab && tab.UnreadMode != UnreadMode.None && tab.Unread > 0)
|
||||
{
|
||||
var iconRight = 10f + contentX + ImGui.CalcTextSize(iconStr).X;
|
||||
dl.AddCircleFilled(origin + new Vector2(iconRight - 2f, 6f), 4f, dangerAbgr, 12);
|
||||
LastRenderedUnreadDotCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (expanded)
|
||||
dl.AddText(origin + new Vector2(32f + contentX, 8f), textAbgr, tab.Name);
|
||||
|
||||
@@ -34,8 +34,29 @@ internal sealed class TopTabBar
|
||||
)
|
||||
)
|
||||
{
|
||||
var previous = activeTab;
|
||||
activeTab = tab;
|
||||
TabLifecycleHelpers.EnsureCurrentChannel(tab);
|
||||
TabLifecycleHelpers.OnTabActivated(tab, previous);
|
||||
}
|
||||
|
||||
// 1.5.6-parity unread dot at the item's top-right. Gate on the
|
||||
// POST-click selection (not the frame-start 'selected') so clicking a
|
||||
// tab suppresses its dot the same frame, like the sidebar. The active
|
||||
// tab is also zeroed every frame (MainWindow.Draw).
|
||||
if (
|
||||
!ReferenceEquals(tab, activeTab)
|
||||
&& tab.UnreadMode != UnreadMode.None
|
||||
&& tab.Unread > 0
|
||||
)
|
||||
{
|
||||
var max = ImGui.GetItemRectMax();
|
||||
var min = ImGui.GetItemRectMin();
|
||||
var danger = ColourUtil.RgbaToAbgr(
|
||||
Plugin.Instance.ThemeRegistry.Active.Colors.StatusDanger
|
||||
);
|
||||
ImGui
|
||||
.GetWindowDrawList()
|
||||
.AddCircleFilled(new Vector2(max.X - 4f, min.Y + 4f), 3.5f, danger, 12);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -119,6 +119,22 @@ internal sealed class MainWindow : Window
|
||||
|
||||
public Tab? ActiveTab => _activeTab;
|
||||
|
||||
// Re-anchors the active-tab reference when the tab it points at is removed
|
||||
// (eviction / logout). Reference compare, so it is immune to the SaveConfig
|
||||
// temp-tab strip window where a tab is briefly absent from Config.Tabs; the
|
||||
// re-seeded tab runs through OnTabActivated so a programmatic switch strips
|
||||
// stale tell state the way a click would.
|
||||
internal void ResetActiveTabIfRemoved(Tab removed)
|
||||
{
|
||||
if (!ReferenceEquals(_activeTab, removed))
|
||||
return;
|
||||
|
||||
var next = Plugin.Config.Tabs.Count > 0 ? Plugin.Config.Tabs[0] : null;
|
||||
_activeTab = next;
|
||||
if (next is not null)
|
||||
TabLifecycleHelpers.OnTabActivated(next, removed);
|
||||
}
|
||||
|
||||
// Internal accessors for self-tests so the probes can reach the live
|
||||
// component without exposing them as public surface.
|
||||
internal Components.Sidebar GetSidebarForSelfTest() => _sidebar;
|
||||
@@ -156,7 +172,31 @@ 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);
|
||||
}
|
||||
else if (_activeTab is { } active && !Plugin.Config.Tabs.Contains(active))
|
||||
{
|
||||
// Active tab is no longer in the list (e.g. a wholesale config import
|
||||
// the service repair paths never see). Re-seed on the Draw thread. The
|
||||
// Contains read shares the pre-existing unsynchronized-Tabs-list
|
||||
// exposure that spec §6 defers (SaveConfig also strips from the worker
|
||||
// thread); this adds one more racing read, not a new hazard class.
|
||||
var reseed = Plugin.Config.Tabs.Count > 0 ? Plugin.Config.Tabs[0] : null;
|
||||
_activeTab = reseed;
|
||||
if (reseed is not null)
|
||||
TabLifecycleHelpers.OnTabActivated(reseed, active);
|
||||
}
|
||||
|
||||
// The active tab's messages are on screen, so it carries no unread badge
|
||||
// (1.5.6 convention: zero the current tab every frame so the dot only ever
|
||||
// shows on tabs you are NOT looking at).
|
||||
if (_activeTab is { } seenTab)
|
||||
seenTab.Unread = 0;
|
||||
|
||||
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)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"Author": "Jon Kazama (Hellion Forge)",
|
||||
"Name": "Hellion Chat",
|
||||
"InternalName": "HellionChat",
|
||||
"AssemblyVersion": "1.8.5.0",
|
||||
"AssemblyVersion": "1.8.6.0",
|
||||
"Description": "A Hellion Forge plugin — privacy-focused chat replacement for FINAL FANTASY XIV, built for EU, US and JP data rules.\n\nBy default only your own conversations are stored. Public chat, NPC dialogue, system messages and battle logs are discarded at the storage layer unless you opt in. Retention windows are configurable per channel, history can be wiped retroactively, and everything can be exported on demand.\n\nFeatures:\n- Channel whitelist with a Privacy-First default\n- Per-channel retention with a daily background sweep\n- Retroactive cleanup with preview and Ctrl+Shift confirm\n- Export to Markdown, JSON or CSV\n- First-run wizard with four profiles: Privacy-First, Casual, Roleplay, Full History\n- Multi-language UI (24 locales) with live language switching\n- Own config and database — no shared state with other plugins\n\nBased on Chat 2 by Infi and Anna (EUPL-1.2).\nSupport: https://discord.gg/X9V7Kcv5gR",
|
||||
"ApplicableVersion": "any",
|
||||
"RepoUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat",
|
||||
@@ -25,7 +25,7 @@
|
||||
"DownloadLinkInstall": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip",
|
||||
"DownloadLinkUpdate": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip",
|
||||
"DownloadLinkTesting": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip",
|
||||
"TestingAssemblyVersion": "1.8.5.0",
|
||||
"TestingAssemblyVersion": "1.8.6.0",
|
||||
"IconUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/icon.png",
|
||||
"ImageUrls": [
|
||||
"https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/chatWindow.png",
|
||||
|
||||
Reference in New Issue
Block a user