diff --git a/ChatTwo.Tests/AutoTellTabsHistoryTest.cs b/ChatTwo.Tests/AutoTellTabsHistoryTest.cs new file mode 100644 index 0000000..0c47836 --- /dev/null +++ b/ChatTwo.Tests/AutoTellTabsHistoryTest.cs @@ -0,0 +1,167 @@ +using System; +using System.IO; +using System.Linq; +using ChatTwo.Code; +using ChatTwo.Util; +using Dalamud.Game.Text; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Game.Text.SeStringHandling.Payloads; +using JetBrains.Annotations; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ChatTwo.Tests; + +// Hellion Chat — Auto-Tell-Tabs history-preload coverage. +// +// These tests exercise MessageStore.GetTellHistoryWithSender, the query the +// AutoTellTabsService uses to populate a freshly spawned temp tab with the +// last conversations with that player. +// +// NOTE: like the rest of ChatTwo.Tests today, these will fail at runtime +// until the project's Dalamud.dll runtime dependency is sorted out (see +// Phase-2 backlog item "Test-Projekt fixen"). Compile-time the suite builds +// fine via DALAMUD_HOME, so the tests guard against API drift even before +// they can be executed locally. +[TestClass] +[TestSubject(typeof(MessageStore))] +public class AutoTellTabsHistoryTest +{ + public TestContext TestContext { get; set; } + + [TestMethod] + [Timeout(5000)] + public void GetTellHistoryWithSender_FiltersByNameAndWorld() + { + var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_"); + var dbPath = Path.Join(tempDir.FullName, "test.db"); + TestContext.WriteLine("Using database path: " + dbPath); + using var store = new MessageStore(dbPath); + + const ulong receiver = 99001; + var now = DateTimeOffset.UtcNow; + + // Two tells with the target sender, one with a different sender on + // the same world, one with the same name on a different world. Only + // the first two should make it into the result. + var asukaLichIn = TellMessage("Asuka", 76, receiver, now.AddMinutes(-30), ChatType.TellIncoming); + var asukaLichOut = TellMessage("Asuka", 76, receiver, now.AddMinutes(-20), ChatType.TellOutgoing); + var broboLich = TellMessage("Brobo", 76, receiver, now.AddMinutes(-10), ChatType.TellIncoming); + var asukaOmega = TellMessage("Asuka", 90, receiver, now.AddMinutes(-5), ChatType.TellIncoming); + + store.UpsertMessage(asukaLichIn); + store.UpsertMessage(asukaLichOut); + store.UpsertMessage(broboLich); + store.UpsertMessage(asukaOmega); + + var result = store.GetTellHistoryWithSender(receiver, "Asuka", 76, limit: 50); + + Assert.AreEqual(2, result.Count); + // Result is oldest-first so a tab can append messages chronologically. + Assert.AreEqual(asukaLichIn.Id, result[0].Id); + Assert.AreEqual(asukaLichOut.Id, result[1].Id); + } + + [TestMethod] + [Timeout(5000)] + public void GetTellHistoryWithSender_RespectsLimit() + { + var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_"); + var dbPath = Path.Join(tempDir.FullName, "test.db"); + TestContext.WriteLine("Using database path: " + dbPath); + using var store = new MessageStore(dbPath); + + const ulong receiver = 99002; + var now = DateTimeOffset.UtcNow; + + for (var i = 0; i < 30; i++) + { + var msg = TellMessage("Asuka", 76, receiver, now.AddMinutes(-i - 1), ChatType.TellIncoming); + store.UpsertMessage(msg); + } + + var result = store.GetTellHistoryWithSender(receiver, "Asuka", 76, limit: 5); + + Assert.AreEqual(5, result.Count); + } + + [TestMethod] + [Timeout(5000)] + public void GetTellHistoryWithSender_ZeroLimitReturnsEmpty() + { + var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_"); + var dbPath = Path.Join(tempDir.FullName, "test.db"); + TestContext.WriteLine("Using database path: " + dbPath); + using var store = new MessageStore(dbPath); + + const ulong receiver = 99003; + + var msg = TellMessage("Asuka", 76, receiver, DateTimeOffset.UtcNow, ChatType.TellIncoming); + store.UpsertMessage(msg); + + var result = store.GetTellHistoryWithSender(receiver, "Asuka", 76, limit: 0); + + Assert.AreEqual(0, result.Count); + } + + [TestMethod] + [Timeout(5000)] + public void GetTellHistoryWithSender_IgnoresOtherReceivers() + { + var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_"); + var dbPath = Path.Join(tempDir.FullName, "test.db"); + TestContext.WriteLine("Using database path: " + dbPath); + using var store = new MessageStore(dbPath); + + const ulong ourReceiver = 99004; + const ulong otherReceiver = 99005; + var now = DateTimeOffset.UtcNow; + + // Tell on the local player's account. + var ours = TellMessage("Asuka", 76, ourReceiver, now.AddMinutes(-1), ChatType.TellIncoming); + // Same sender, but logged against a different local character — + // common when the user has alts. Must not surface. + var foreign = TellMessage("Asuka", 76, otherReceiver, now, ChatType.TellIncoming); + + store.UpsertMessage(ours); + store.UpsertMessage(foreign); + + var result = store.GetTellHistoryWithSender(ourReceiver, "Asuka", 76, limit: 50); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual(ours.Id, result[0].Id); + } + + private static Message TellMessage( + string senderName, + uint senderWorld, + ulong receiver, + DateTimeOffset dateTime, + ChatType chatType) + { + var senderSeString = new SeStringBuilder() + .Add(new PlayerPayload(senderName, senderWorld)) + .AddText(senderName) + .Add(RawPayload.LinkTerminator) + .Build(); + + var contentSeString = new SeStringBuilder() + .AddText("test message") + .Build(); + + var senderChunks = ChunkUtil.ToChunks(senderSeString, ChunkSource.Sender, chatType).ToList(); + var contentChunks = ChunkUtil.ToChunks(contentSeString, ChunkSource.Content, chatType).ToList(); + + var chatCode = new ChatCode((XivChatType)chatType, XivChatRelationKind.LocalPlayer, XivChatRelationKind.LocalPlayer); + return new Message( + Guid.NewGuid(), + receiver, + 0, + dateTime, + chatCode, + senderChunks, + contentChunks, + senderSeString, + contentSeString, + Guid.Empty); + } +} diff --git a/ChatTwo/AutoTellTabsService.cs b/ChatTwo/AutoTellTabsService.cs new file mode 100644 index 0000000..35ca374 --- /dev/null +++ b/ChatTwo/AutoTellTabsService.cs @@ -0,0 +1,358 @@ +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. + PreloadHistory(tab, partner.Name, partner.World); + + 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.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) + { + var preloadCount = Plugin.Config.AutoTellTabsHistoryPreload; + if (preloadCount <= 0) + { + return; + } + + try + { + var history = _store.GetTellHistoryWithSender( + _messageManager.CurrentContentId, + senderName, + senderWorld, + preloadCount); + + if (history.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 history) + { + 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; + } + } + } +} diff --git a/ChatTwo/ChatTwo.csproj b/ChatTwo/ChatTwo.csproj index 6cce6af..d1d9dce 100755 --- a/ChatTwo/ChatTwo.csproj +++ b/ChatTwo/ChatTwo.csproj @@ -4,7 +4,7 @@ 0.1.0 is our bootstrap release; the underlying Chat 2 base is called out in the yaml changelog so users can see what it derives from. --> - 0.3.1 + 0.4.0 enable + + Auto-Tell-Tabs + + + Auto-Tell-Tabs sind ab Version 0.4.0 standardmäßig aktiv. Du kannst sie im Chat-Tab deaktivieren oder anpassen. + + + Aktive Tells + + + — Frühere Unterhaltungen — + + + Verlauf konnte nicht geladen werden. + + + Als begrüßt markiert. Klicken um die Markierung zu entfernen. + + + Als begrüßt markieren. + + + + + Auto-Tell-Tabs + + + Bei jedem /tell automatisch einen Tab pro Gesprächspartner öffnen + + + Sobald du einen /tell empfängst oder sendest, wird automatisch ein temporärer Tab für diesen Spieler geöffnet. Die Tabs verschwinden beim Logout. + + + Maximale Anzahl der Auto-Tell-Tabs + + + Beim Erreichen werden begrüßte Tabs mit der ältesten Aktivität zuerst geschlossen. Änderungen greifen beim nächsten /tell. + + + Kompakte Anzeige + + + Zeigt nur einen dünnen Separator zwischen normalen Tabs und Auto-Tell-Tabs, ohne Sektions-Header. + + + „Als begrüßt markieren"-Button anzeigen + + + Fügt neben jedem Auto-Tell-Tab einen Klick-Button hinzu, um einen Gesprächspartner als bereits begrüßt zu markieren — der Tab-Name wird dann gedimmt. Nützlich für Club-Greeter, die parallel viele Konversationen führen. Standardmäßig aus. + + + Die Anzahl der vorgeladenen Tells lässt sich im Datenschutz-Tab einstellen. + + + Hinweis: Falls XIV Messanger oder ein ähnliches Plugin Tells unterdrückt, dort die Option „Suppress DMs" deaktivieren, damit Hellion Chat Tells empfangen und die Auto-Tabs öffnen kann. + + + + + Tell-Verlauf in Auto-Tabs + + + Anzahl der vorgeladenen Tells + + + Wie viele frühere Tell-Nachrichten beim Öffnen eines Auto-Tell-Tabs aus der Datenbank geladen werden. 0 deaktiviert die Vorladung. + + + Greift nur, wenn Auto-Tell-Tabs im Chat-Tab aktiviert sind. + diff --git a/ChatTwo/Resources/HellionStrings.resx b/ChatTwo/Resources/HellionStrings.resx index 52c1d43..01cb688 100644 --- a/ChatTwo/Resources/HellionStrings.resx +++ b/ChatTwo/Resources/HellionStrings.resx @@ -366,4 +366,76 @@ Chat 2 community translators (upstream) + + + + Auto-Tell-Tabs + + + Auto-Tell-Tabs are enabled by default starting with version 0.4.0. You can disable or fine-tune them in the Chat tab. + + + Active Tells + + + — Earlier conversations — + + + History could not be loaded. + + + Marked as greeted. Click to remove the marker. + + + Mark as greeted. + + + + + Auto-Tell-Tabs + + + Open a tab automatically for each tell partner + + + When you receive or send a /tell, a temporary tab dedicated to that player is opened automatically. Tabs vanish on logout. + + + Maximum number of auto tell tabs + + + When the limit is reached, greeted tabs with the oldest activity are dropped first. The change applies on the next /tell. + + + Compact display + + + Show only a thin separator between persistent tabs and auto tell tabs, without the section header. + + + Show "mark as greeted" button + + + Adds a click-to-toggle button next to each auto tell tab to mark a partner as already greeted, dimming the tab name when set. Useful for club greeters tracking many parallel conversations; off by default. + + + The number of preloaded tells is configured in the Privacy tab. + + + Heads-up: if XIV Messanger or a similar plugin is suppressing direct messages, turn its "Suppress DMs" option off so Hellion Chat can receive tells and open the auto tabs. + + + + + Tell history in auto tabs + + + Number of preloaded tells + + + How many earlier tell messages are loaded from the database when an auto tell tab is opened. 0 disables the preload. + + + Only takes effect when auto tell tabs are enabled in the Chat tab. + diff --git a/ChatTwo/Ui/ChatLogWindow.cs b/ChatTwo/Ui/ChatLogWindow.cs index 018085c..0ba15f6 100644 --- a/ChatTwo/Ui/ChatLogWindow.cs +++ b/ChatTwo/Ui/ChatLogWindow.cs @@ -1303,14 +1303,80 @@ public sealed class ChatLogWindow : Window if (child) { var previousTab = Plugin.CurrentTab; + // Hellion Chat — auto-tell-tabs section divider rendered + // exactly once before the first temp tab, with a live unit + // counter pulled directly from the tab list. + var tempTabHeaderRendered = false; + var tempTabCount = Plugin.Config.Tabs.Count(t => t.IsTempTab); + for (var tabI = 0; tabI < Plugin.Config.Tabs.Count; tabI++) { var tab = Plugin.Config.Tabs[tabI]; if (tab.PopOut) continue; + if (tab.IsTempTab && !tempTabHeaderRendered) + { + ImGui.Separator(); + if (!Plugin.Config.AutoTellTabsCompactDisplay) + { + ImGui.TextDisabled($"{HellionStrings.AutoTellTabs_SectionHeader} ({tempTabCount})"); + } + tempTabHeaderRendered = true; + } + var unread = tabI == Plugin.LastTab || tab.UnreadMode == UnreadMode.None || tab.Unread == 0 ? "" : $" ({tab.Unread})"; - var clicked = ImGui.Selectable($"{tab.Name}{unread}###log-tab-{tabI}", Plugin.LastTab == tabI || Plugin.WantedTab == tabI); + var selectableLabel = $"{tab.Name}{unread}###log-tab-{tabI}"; + var isCurrentTab = Plugin.LastTab == tabI || Plugin.WantedTab == tabI; + + var showGreetedAffordance = tab.IsTempTab && Plugin.Config.AutoTellTabsShowGreetedToggle; + + if (showGreetedAffordance) + { + // Greeted toggle sits left of the selectable so the + // click areas stay separate. The icon also doubles + // as the visual "I'm done with this person" cue. + // Compact frame padding keeps the icon dezent next + // to the tab name instead of a chunky button block. + var greetedIcon = tab.IsGreeted ? FontAwesomeIcon.CheckCircle : FontAwesomeIcon.Check; + var greetedTooltip = tab.IsGreeted + ? HellionStrings.AutoTellTabs_GreetedTooltip + : HellionStrings.AutoTellTabs_UnGreetedTooltip; + + using (ImRaii.PushStyle(ImGuiStyleVar.FramePadding, new Vector2(2, 1))) + using (ImRaii.PushColor(ImGuiCol.Button, 0)) + { + if (ImGuiUtil.IconButton(greetedIcon, $"greeted-{tabI}", greetedTooltip)) + { + if (tab.IsGreeted) + { + Plugin.AutoTellTabsService.UnmarkGreeted(tab); + } + else + { + Plugin.AutoTellTabsService.MarkGreeted(tab); + } + } + } + ImGui.SameLine(); + } + + bool clicked; + if (showGreetedAffordance && tab.IsGreeted) + { + // Dim the tab name once the user marked the partner + // as greeted, so a glance at the sidebar tells them + // who still needs attention. + using (ImRaii.PushColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.TextDisabled))) + { + clicked = ImGui.Selectable(selectableLabel, isCurrentTab); + } + } + else + { + clicked = ImGui.Selectable(selectableLabel, isCurrentTab); + } + DrawTabContextMenu(tab, tabI); if (!clicked && Plugin.WantedTab != tabI) diff --git a/ChatTwo/Ui/SettingsTabs/ChatLog.cs b/ChatTwo/Ui/SettingsTabs/ChatLog.cs index b8bb944..a88f5be 100644 --- a/ChatTwo/Ui/SettingsTabs/ChatLog.cs +++ b/ChatTwo/Ui/SettingsTabs/ChatLog.cs @@ -1,6 +1,7 @@ using ChatTwo.Resources; using ChatTwo.Util; using Dalamud.Interface.Style; +using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Bindings.ImGui; @@ -92,6 +93,12 @@ internal sealed class ChatLog : ISettingsTab Plugin.ChatLogWindow.Position = pos; ImGuiUtil.WarningText(Language.Options_AdjustPosition_Warning); ImGui.Spacing(); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + DrawAutoTellTabsSection(); } if (!Mutable.OverrideStyle) @@ -116,4 +123,37 @@ internal sealed class ChatLog : ISettingsTab ImGui.Spacing(); } + + private void DrawAutoTellTabsSection() + { + using var tree = ImRaii.TreeNode(HellionStrings.ChatLog_AutoTellTabs_Section_Title); + if (!tree.Success) + return; + + using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) + { + ImGui.Checkbox(HellionStrings.ChatLog_AutoTellTabs_Enable_Name, ref Mutable.EnableAutoTellTabs); + ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_Enable_Description); + + ImGui.SetNextItemWidth(200f * ImGuiHelpers.GlobalScale); + var limit = Mutable.AutoTellTabsLimit; + if (ImGui.SliderInt(HellionStrings.ChatLog_AutoTellTabs_Limit_Name, ref limit, 1, 50)) + { + Mutable.AutoTellTabsLimit = limit; + } + ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_Limit_Description); + + ImGui.Checkbox(HellionStrings.ChatLog_AutoTellTabs_Compact_Name, ref Mutable.AutoTellTabsCompactDisplay); + ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_Compact_Description); + + ImGui.Checkbox(HellionStrings.ChatLog_AutoTellTabs_GreetedToggle_Name, ref Mutable.AutoTellTabsShowGreetedToggle); + ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_GreetedToggle_Description); + + ImGui.Spacing(); + ImGuiUtil.HelpText(HellionStrings.ChatLog_AutoTellTabs_PreloadHint); + + ImGui.Spacing(); + ImGuiUtil.WarningText(HellionStrings.ChatLog_AutoTellTabs_ConflictHint); + } + } } diff --git a/ChatTwo/Ui/SettingsTabs/Privacy.cs b/ChatTwo/Ui/SettingsTabs/Privacy.cs index 9db5cfe..46d3ca9 100644 --- a/ChatTwo/Ui/SettingsTabs/Privacy.cs +++ b/ChatTwo/Ui/SettingsTabs/Privacy.cs @@ -4,6 +4,7 @@ using ChatTwo.Privacy; using ChatTwo.Resources; using ChatTwo.Util; using Dalamud.Interface.ImGuiNotification; +using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Bindings.ImGui; @@ -186,6 +187,33 @@ internal sealed class Privacy : ISettingsTab ImGui.Spacing(); DrawExportSection(); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + DrawAutoTellTabsPreloadSection(); + } + + private void DrawAutoTellTabsPreloadSection() + { + using var tree = ImRaii.TreeNode(HellionStrings.Privacy_AutoTellTabs_Section_Title); + if (!tree.Success) + return; + + using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) + { + var preload = Mutable.AutoTellTabsHistoryPreload; + ImGui.SetNextItemWidth(200f * ImGuiHelpers.GlobalScale); + if (ImGui.SliderInt(HellionStrings.Privacy_AutoTellTabs_Preload_Name, ref preload, 0, 100)) + { + Mutable.AutoTellTabsHistoryPreload = preload; + } + ImGuiUtil.HelpMarker(HellionStrings.Privacy_AutoTellTabs_Preload_Description); + + ImGui.Spacing(); + ImGuiUtil.HelpText(HellionStrings.Privacy_AutoTellTabs_Preload_Hint); + } } private void DrawExportSection() diff --git a/ChatTwo/Util/ChunkUtil.cs b/ChatTwo/Util/ChunkUtil.cs index 6f4728c..8077ac7 100755 --- a/ChatTwo/Util/ChunkUtil.cs +++ b/ChatTwo/Util/ChunkUtil.cs @@ -398,6 +398,60 @@ internal static class ChunkUtil return builder.ToString(); } + // Hellion Chat — shared helper for Auto-Tell-Tabs and the MessageStore + // history-preload query. Walks the chunk list once and returns the + // first PlayerPayload it finds, or null when the message has no + // resolved player link (e.g. system messages, GM tells we already + // skipped earlier in the pipeline). + internal static PlayerPayload? TryGetPlayerPayload(IReadOnlyList chunks) + { + foreach (var chunk in chunks) + { + if (chunk.Link is PlayerPayload pp) + { + return pp; + } + } + return null; + } + + // Fallback for tells where the PlayerPayload lives in the raw SeString + // payload list rather than on a chunk's Link slot. Same semantics as + // the chunk-walking variant above: returns the first PlayerPayload or + // null if the SeString has none. + internal static PlayerPayload? TryGetPlayerPayload(SeString? seString) + { + if (seString == null) + { + return null; + } + foreach (var payload in seString.Payloads) + { + if (payload is PlayerPayload pp) + { + return pp; + } + } + return null; + } + + // True when the message's sender (or, as a fallback, content) carries a + // PlayerPayload that matches the given identity. Used by both the + // Tab.Matches sender filter and the MessageStore tell-history scan. + internal static bool MatchesSender(Message message, string senderName, uint senderWorld) + { + var payload = TryGetPlayerPayload(message.Sender) ?? TryGetPlayerPayload(message.Content); + if (payload == null) + { + return false; + } + if (!string.Equals(payload.PlayerName, senderName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + return payload.World.RowId == senderWorld; + } + internal static readonly RawPayload PeriodicRecruitmentLink = new([0x02, 0x27, 0x07, 0x08, 0x01, 0x01, 0x01, 0xFF, 0x01, 0x03]); private static uint GetInteger(BinaryReader input) diff --git a/ChatTwo/Util/ImGuiUtil.cs b/ChatTwo/Util/ImGuiUtil.cs index 75b17fb..366dcce 100755 --- a/ChatTwo/Util/ImGuiUtil.cs +++ b/ChatTwo/Util/ImGuiUtil.cs @@ -215,6 +215,24 @@ internal static class ImGuiUtil ImGui.TextUnformatted(text); } + // Hellion Chat — compact help affordance: a dimmed "(?)" glyph rendered + // on the same line as the previous item, with the long-form description + // tucked into a hover tooltip. Lets us keep the settings panes scannable + // instead of stacking a wall of HelpText paragraphs under every option. + internal static void HelpMarker(string description) + { + ImGui.SameLine(); + using (ImRaii.PushColor(ImGuiCol.Text, ImGui.GetStyle().Colors[(int) ImGuiCol.TextDisabled])) + ImGui.TextUnformatted("(?)"); + + if (!ImGui.IsItemHovered()) + return; + + using var tooltip = ImRaii.Tooltip(); + using (ImRaii.TextWrapPos(35.0f * ImGui.GetFontSize())) + ImGui.TextUnformatted(description); + } + internal static void WarningText(string text, bool wrap = true) { var style = StyleModel.GetConfiguredStyle() ?? StyleModel.GetFromCurrent(); diff --git a/repo.json b/repo.json index 37c8071..5d7f239 100644 --- a/repo.json +++ b/repo.json @@ -3,7 +3,7 @@ "Author": "JonKazama-Hellion", "Name": "Hellion Chat", "InternalName": "HellionChat", - "AssemblyVersion": "0.3.1.0", + "AssemblyVersion": "0.4.0.0", "Description": "Hellion Chat is built on top of Chat 2 with one removal and a stack\nof privacy controls on top. Tabs, channel filters, RGB colours,\nemotes, screenshot mode, IPC integration and the chat replacement\nwindow itself work the same. The optional webinterface that Chat 2\nships is intentionally not part of this fork because it serves a\ndifferent use case from the smaller default footprint Hellion Chat\nis built around.\n\nOn top of that, Hellion Chat adds privacy and data-handling controls\ndesigned to align with the modern data protection rules that apply\nacross the EU, the United States and Japan. By default only your own\nconversations are stored; messages from strangers, NPCs and system\nspam stay out of the database. Retention windows are configurable per\nchannel, history can be wiped retroactively, and stored data can be\nexported on demand.\n\nKey additions on top of Chat 2:\n\n- Channel whitelist with a Privacy-First default\n- Per-channel retention with a daily background sweep\n- Retroactive cleanup with a Ctrl+Shift confirm\n- Export to Markdown, JSON or CSV\n- First-run wizard with three preset profiles (Privacy-First, Casual,\n Full History)\n- Bilingual UI (English and German) with live language switching\n- Independent plugin state — own config file and database directory,\n so Hellion Chat does not share state with the upstream plugin\n\nBased on Chat 2 by Infi and Anna, licensed under EUPL-1.2.", "ApplicableVersion": "any", "RepoUrl": "https://github.com/JonKazama-Hellion/HellionChat", @@ -20,12 +20,12 @@ "CanUnloadAsync": false, "LoadPriority": 0, "Punchline": "Chat 2 with privacy controls aligned to EU, US and JP rules", - "Changelog": "**Hellion Chat 0.3.1 — Upstream emote regression fix**\n\nCherry-picks Infi's upstream commit ff899ff \"Fix a regression\nfrom API 15 updates\" which changes the BetterTTV emote DTOs\n(Emote and Top100) from public fields to public properties.\nSystem.Text.Json under the API 15 toolchain only honours the\n[JsonPropertyName] attribute on properties, so the previous\nfield-based version deserialised every fetched emote into empty\ndefault values. Result: BetterTTV emotes were silently broken\non fresh installs. The fix is six lines and applies cleanly on\ntop of our defensive null-check from earlier; the EmoteCache\npath-traversal hardening from 0.3.0 stays as it is.\n\nAuthorship of the fix is preserved with git cherry-pick -x, so\nInfi shows up as the author on the commit. Thanks to him for\ncatching it in the upstream codebase.\n\n**Hellion Chat 0.3.0 — Audit hardening, brand sweep and rebrand of slash commands**\n\nThis release closes the remaining audit follow-ups from the\n0.2.0 cleanup and finishes turning Hellion Chat into a properly\nbranded fork rather than a Chat 2 with a different name.\n\nSlash commands have been renamed across the board so they no\nlonger collide with the upstream plugin and tell you which\nplugin owns them at a glance:\n\n- /chat2 becomes /hellion\n- /chat2Viewer becomes /hellionView\n- /clearlog2 becomes /clearhellion\n- /chat2Debugger becomes /hellionDebugger (internal)\n- /chat2SeString becomes /hellionSeString (internal)\n\nThis is a breaking change for anyone with macros bound to the\nold command names. The upstream Chat 2 commands keep working\nif you also have that plugin installed.\n\nPrivacy and storage hardening based on the post-0.2.0 audit:\n\n- Privacy filter master switch now states explicitly that the\n filter only governs storage, not the live chat log\n- Emote cache refuses to write outside its own directory if a\n third-party API ever returns a path that escapes\n- Retention sweep is serialised so the 24h auto-sweep and the\n manual button cannot launch in parallel and race for the\n SQLite connection\n- DbViewer paging uses an int constant and the matching SQL\n parameter name (the upstream code passed a float and a name\n without the parameter prefix; both worked in practice but\n were inconsistent)\n\nVisual identity now matches the Hellion Online Media website:\n\n- Theme palette switched to Arctic Cyan plus Ember Orange,\n matching the website's BRANDING.md tokens\n- Active tabs and window title bars use a brand-color-dark teal\n variation as identity colour, replacing the previous slate\n violet that did not appear in the brand\n- Resize grips and scrollbar grabs picked up Ember Orange\n instead of industrial amber on hover and active states\n\nAbout tab rewritten and properly localised:\n\n- New \"Why this fork exists\" block sets out the mission in\n neutral terms, framing Chat 2's full-history default as the\n right one for most users while explaining the narrower\n default footprint this fork chose\n- All Hellion-specific About copy now lives in HellionStrings\n in EN and DE, so German users see the Hellion sections in\n German rather than the upstream English fallback\n- Webinterface absence is described as a focus mismatch\n (different use case, substantial rebuild) rather than as\n a security issue with the upstream code\n- Translator list at the bottom of the About tab is reachable\n again on smaller settings windows\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n**Hellion Chat 0.2.0 — Webinterface removed**\n\nWhat changed in this release:\n\n- Settings tab \"Webinterface\" is gone, the corresponding\n Configuration fields (WebinterfaceEnabled / AutoStart / Password /\n Port / AuthStore / MaxLinesToSend) are dropped and stale entries\n fall out of the JSON on the next save automatically\n- The whole ChatTwo/Http tree, the bundled Svelte frontend in\n websiteBuild.zip and the WebinterfaceUtil helper are deleted\n- Watson.Lite (the HTTP server) and Newtonsoft.Json (only used by\n the webinterface JSON wire format) are removed from the\n package references\n- DbViewer's \"Chat2 JSON Export\" button is dropped because it\n serialised the database into the webinterface message protocol;\n the Privacy tab's MessageExporter (Markdown, JSON, CSV with\n channel and date filters) covers the same ground without the\n proprietary shape\n- About tab notes the absence so users coming from Chat 2 do not\n look for it\n- Configuration version bumps from 7 to 8 with a one-shot\n notification (EN + DE)\n\nNo changes to the privacy filter, retention sweep, first-run wizard\nor export pipeline. Existing chat history is preserved.\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n**Hellion Chat 0.1.2 — About tab rebrand, DBViewer polish**\n\n- About tab now shows Hellion-specific maintainer, license, EU/US/JP\n disclaimer and SQUARE ENIX disclaimer instead of the inherited\n Chat 2 contact info; original ChatTwo translator credits stay\n visible under a clearly labelled upstream tree node\n- Localization clarified: Hellion-specific German strings are\n maintained by the fork maintainer, the Crowdin contributor list\n only covers the inherited upstream strings\n- Cherry-picked DBViewer UI improvements from upstream Chat 2\n (auto-scroll-reset on page change, tooltips on date reset,\n folder export, page arrows, localized export-running messages)\n- README rewritten in the Hellion project style with a tech-stack\n table, architecture tree, database column list, install guide,\n upstream-sync workflow notes and project-status checklist\n\n**Hellion Chat 0.1.1 — Packaging and migration fixes**\n\n- Plugin icon now ships inside the bundle, so the Hellion logo\n renders locally in the Dalamud plugin list once installed (the\n previous release relied only on the remote IconUrl)\n- Plugin icon downsampled from 1024×1024 to 256×256 to match the\n rendered size; loads faster and caches better\n- Migration from upstream Chat 2 is more robust: each file move is\n wrapped individually, a locked SQLite database no longer aborts\n the rest of the migration, and a warning notification fires when\n any file is held open (with a hint to disable Chat 2 and restart\n the game)\n- README ships a step-by-step migration guide (fresh install versus\n coming from Chat 2) and a troubleshooting section with manual\n recovery commands for Linux and Windows\n\n**Hellion Chat 0.1.0 — Initial fork release**\n\nPrivacy\n- Channel whitelist filter in MessageStore.UpsertMessage with a\n Privacy-First default (own conversations only)\n- Per-channel retention with a 24-hour idempotent background sweep\n- Retroactive cleanup with a Ctrl+Shift confirm and VACUUM\n- Export to Markdown / JSON / CSV via Dalamud's file dialog\n\nOnboarding\n- First-run wizard with three profiles: Privacy-First / Casual /\n Full History\n- Configuration migration that seeds defaults on update\n- One-shot migration from upstream Chat 2's pluginConfigs layout\n- Migrate3 idempotency recovery for half-migrated databases\n\nLook & feel\n- Localized UI (English and German) with live language switching\n- Industrial HUD theme with cyan-teal action accents, slate-violet\n tabs, amber active highlights and a window-opacity slider\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).", + "Changelog": "**Hellion Chat 0.4.0 — Auto-Tell-Tabs**\n\nAuto-Tell-Tabs lets you turn each /tell into a session-only tab\ndedicated to that conversation partner. The original use case is\nthe FFXIV club greeter who has to track 5–15 parallel \"hi, welcome\"\nexchanges; everyone else can disable the feature in one click and\ngo back to a single Tell Exclusive tab.\n\nWhat lands in this release:\n\n- Auto-spawn temp tab \"Name@World\" on /tell (incoming and outgoing)\n- Tab limit (default 15, range 1–50) with LRU drop that prefers\n greeted tabs first, then sorts by last activity\n- History preload from the local message store (default 20 tells,\n range 0–100) with a \"— Earlier conversations —\" separator above\n the live tell that triggered the spawn\n- Optional \"mark as greeted\" toggle button (off by default,\n greeter-specific) that dims the tab name and lets you flip the\n status\n- Section header \"Active Tells (n)\" or compact-mode separator in\n the sidebar between persistent tabs and the temp tabs\n- Settings UI under Chat (toggle / limit / compact / greeted-toggle)\n and Privacy (history preload count), with hover-tooltip help\n markers replacing the previous wall-of-text descriptions for the\n new sections\n- Save and load filters strip temp tabs from the on-disk config so\n a crash or a sidebar-mode toggle never persists or wipes them\n\nCompatibility note: if XIV Messanger or another plugin is\nsuppressing direct messages, disable its \"Suppress DMs\" option so\nHellion Chat can receive tells and open the auto tabs.\n\nConfiguration version bumps from 8 to 9. Existing users get a one-\nshot notification on the first start, defaults are seeded by\nproperty initializers, persistent tabs are untouched.\n\nThe vertical sidebar tab view becomes the default for fresh\ninstalls; existing users keep their saved preference.\n\nInspired by the per-sender tab pattern in XIV InstantMessenger\n(Limiana, AGPL-3.0). No code was ported across the licence\nboundary; only the architectural concept influenced this design.\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n**Hellion Chat 0.3.1 — Upstream emote regression fix**\n\nCherry-picks Infi's upstream commit ff899ff \"Fix a regression\nfrom API 15 updates\" which changes the BetterTTV emote DTOs\n(Emote and Top100) from public fields to public properties.\nSystem.Text.Json under the API 15 toolchain only honours the\n[JsonPropertyName] attribute on properties, so the previous\nfield-based version deserialised every fetched emote into empty\ndefault values. Result: BetterTTV emotes were silently broken\non fresh installs. The fix is six lines and applies cleanly on\ntop of our defensive null-check from earlier; the EmoteCache\npath-traversal hardening from 0.3.0 stays as it is.\n\nAuthorship of the fix is preserved with git cherry-pick -x, so\nInfi shows up as the author on the commit. Thanks to him for\ncatching it in the upstream codebase.\n\n**Hellion Chat 0.3.0 — Audit hardening, brand sweep and rebrand of slash commands**\n\nThis release closes the remaining audit follow-ups from the\n0.2.0 cleanup and finishes turning Hellion Chat into a properly\nbranded fork rather than a Chat 2 with a different name.\n\nSlash commands have been renamed across the board so they no\nlonger collide with the upstream plugin and tell you which\nplugin owns them at a glance:\n\n- /chat2 becomes /hellion\n- /chat2Viewer becomes /hellionView\n- /clearlog2 becomes /clearhellion\n- /chat2Debugger becomes /hellionDebugger (internal)\n- /chat2SeString becomes /hellionSeString (internal)\n\nThis is a breaking change for anyone with macros bound to the\nold command names. The upstream Chat 2 commands keep working\nif you also have that plugin installed.\n\nPrivacy and storage hardening based on the post-0.2.0 audit:\n\n- Privacy filter master switch now states explicitly that the\n filter only governs storage, not the live chat log\n- Emote cache refuses to write outside its own directory if a\n third-party API ever returns a path that escapes\n- Retention sweep is serialised so the 24h auto-sweep and the\n manual button cannot launch in parallel and race for the\n SQLite connection\n- DbViewer paging uses an int constant and the matching SQL\n parameter name (the upstream code passed a float and a name\n without the parameter prefix; both worked in practice but\n were inconsistent)\n\nVisual identity now matches the Hellion Online Media website:\n\n- Theme palette switched to Arctic Cyan plus Ember Orange,\n matching the website's BRANDING.md tokens\n- Active tabs and window title bars use a brand-color-dark teal\n variation as identity colour, replacing the previous slate\n violet that did not appear in the brand\n- Resize grips and scrollbar grabs picked up Ember Orange\n instead of industrial amber on hover and active states\n\nAbout tab rewritten and properly localised:\n\n- New \"Why this fork exists\" block sets out the mission in\n neutral terms, framing Chat 2's full-history default as the\n right one for most users while explaining the narrower\n default footprint this fork chose\n- All Hellion-specific About copy now lives in HellionStrings\n in EN and DE, so German users see the Hellion sections in\n German rather than the upstream English fallback\n- Webinterface absence is described as a focus mismatch\n (different use case, substantial rebuild) rather than as\n a security issue with the upstream code\n- Translator list at the bottom of the About tab is reachable\n again on smaller settings windows\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n**Hellion Chat 0.2.0 — Webinterface removed**\n\nWhat changed in this release:\n\n- Settings tab \"Webinterface\" is gone, the corresponding\n Configuration fields (WebinterfaceEnabled / AutoStart / Password /\n Port / AuthStore / MaxLinesToSend) are dropped and stale entries\n fall out of the JSON on the next save automatically\n- The whole ChatTwo/Http tree, the bundled Svelte frontend in\n websiteBuild.zip and the WebinterfaceUtil helper are deleted\n- Watson.Lite (the HTTP server) and Newtonsoft.Json (only used by\n the webinterface JSON wire format) are removed from the\n package references\n- DbViewer's \"Chat2 JSON Export\" button is dropped because it\n serialised the database into the webinterface message protocol;\n the Privacy tab's MessageExporter (Markdown, JSON, CSV with\n channel and date filters) covers the same ground without the\n proprietary shape\n- About tab notes the absence so users coming from Chat 2 do not\n look for it\n- Configuration version bumps from 7 to 8 with a one-shot\n notification (EN + DE)\n\nNo changes to the privacy filter, retention sweep, first-run wizard\nor export pipeline. Existing chat history is preserved.\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n**Hellion Chat 0.1.2 — About tab rebrand, DBViewer polish**\n\n- About tab now shows Hellion-specific maintainer, license, EU/US/JP\n disclaimer and SQUARE ENIX disclaimer instead of the inherited\n Chat 2 contact info; original ChatTwo translator credits stay\n visible under a clearly labelled upstream tree node\n- Localization clarified: Hellion-specific German strings are\n maintained by the fork maintainer, the Crowdin contributor list\n only covers the inherited upstream strings\n- Cherry-picked DBViewer UI improvements from upstream Chat 2\n (auto-scroll-reset on page change, tooltips on date reset,\n folder export, page arrows, localized export-running messages)\n- README rewritten in the Hellion project style with a tech-stack\n table, architecture tree, database column list, install guide,\n upstream-sync workflow notes and project-status checklist\n\n**Hellion Chat 0.1.1 — Packaging and migration fixes**\n\n- Plugin icon now ships inside the bundle, so the Hellion logo\n renders locally in the Dalamud plugin list once installed (the\n previous release relied only on the remote IconUrl)\n- Plugin icon downsampled from 1024×1024 to 256×256 to match the\n rendered size; loads faster and caches better\n- Migration from upstream Chat 2 is more robust: each file move is\n wrapped individually, a locked SQLite database no longer aborts\n the rest of the migration, and a warning notification fires when\n any file is held open (with a hint to disable Chat 2 and restart\n the game)\n- README ships a step-by-step migration guide (fresh install versus\n coming from Chat 2) and a troubleshooting section with manual\n recovery commands for Linux and Windows\n\n**Hellion Chat 0.1.0 — Initial fork release**\n\nPrivacy\n- Channel whitelist filter in MessageStore.UpsertMessage with a\n Privacy-First default (own conversations only)\n- Per-channel retention with a 24-hour idempotent background sweep\n- Retroactive cleanup with a Ctrl+Shift confirm and VACUUM\n- Export to Markdown / JSON / CSV via Dalamud's file dialog\n\nOnboarding\n- First-run wizard with three profiles: Privacy-First / Casual /\n Full History\n- Configuration migration that seeds defaults on update\n- One-shot migration from upstream Chat 2's pluginConfigs layout\n- Migrate3 idempotency recovery for half-migrated databases\n\nLook & feel\n- Localized UI (English and German) with live language switching\n- Industrial HUD theme with cyan-teal action accents, slate-violet\n tabs, amber active highlights and a window-opacity slider\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).", "AcceptsFeedback": true, - "DownloadLinkInstall": "https://github.com/JonKazama-Hellion/HellionChat/releases/download/v0.3.1/latest.zip", - "DownloadLinkUpdate": "https://github.com/JonKazama-Hellion/HellionChat/releases/download/v0.3.1/latest.zip", - "DownloadLinkTesting": "https://github.com/JonKazama-Hellion/HellionChat/releases/download/v0.3.1/latest.zip", - "TestingAssemblyVersion": "0.3.1.0", + "DownloadLinkInstall": "https://github.com/JonKazama-Hellion/HellionChat/releases/download/v0.4.0/latest.zip", + "DownloadLinkUpdate": "https://github.com/JonKazama-Hellion/HellionChat/releases/download/v0.4.0/latest.zip", + "DownloadLinkTesting": "https://github.com/JonKazama-Hellion/HellionChat/releases/download/v0.4.0/latest.zip", + "TestingAssemblyVersion": "0.4.0.0", "IconUrl": "https://raw.githubusercontent.com/JonKazama-Hellion/HellionChat/main/ChatTwo/images/icon.png", "ImageUrls": [], "DownloadCount": 0,