From da3c1f683263f1e4609e2890ee2e1b41a0dbfef5 Mon Sep 17 00:00:00 2001 From: JonKazama-Hellion Date: Sat, 2 May 2026 21:17:57 +0200 Subject: [PATCH 01/12] fix(emotes): mark required properties to silence CS8618 Mark Emote.Id, Top100.Id, Top100.Code and Top100.ImageType as required so the JSON deserializer enforces the contract instead of relying on default-null semantics. Removes the four CS8618 warnings the build has been carrying since v0.4.0. --- ChatTwo/EmoteCache.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ChatTwo/EmoteCache.cs b/ChatTwo/EmoteCache.cs index 34a7c20..366220d 100644 --- a/ChatTwo/EmoteCache.cs +++ b/ChatTwo/EmoteCache.cs @@ -35,20 +35,20 @@ public static class EmoteCache public Emote Emote { get; set; } [JsonPropertyName("id")] - public string Id { get; set; } + public required string Id { get; set; } } [Serializable] public struct Emote() { [JsonPropertyName("id")] - public string Id { get; set; } + public required string Id { get; set; } [JsonPropertyName("code")] - public string Code { get; set; } + public required string Code { get; set; } [JsonPropertyName("imageType")] - public string ImageType { get; set; } + public required string ImageType { get; set; } } public enum LoadingState From 7de28ef9b206799e72d1cc33cc199293dc14420d Mon Sep 17 00:00:00 2001 From: JonKazama-Hellion Date: Sat, 2 May 2026 21:18:30 +0200 Subject: [PATCH 02/12] refactor(settings): align performance section with helpmarker pattern Drop the inline wall-of-text description in favour of the standard HelpMarker tooltip used across the rest of the v0.5.0 settings UX. Visual consistency for the General tab. --- ChatTwo/Ui/SettingsTabs/General.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ChatTwo/Ui/SettingsTabs/General.cs b/ChatTwo/Ui/SettingsTabs/General.cs index 60ea048..2202243 100644 --- a/ChatTwo/Ui/SettingsTabs/General.cs +++ b/ChatTwo/Ui/SettingsTabs/General.cs @@ -1,5 +1,6 @@ using ChatTwo.Resources; using ChatTwo.Util; +using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Bindings.ImGui; @@ -81,10 +82,12 @@ internal sealed class General : ISettingsTab using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) { - if (ImGuiUtil.InputIntVertical(Language.Options_MaxLinesToShow_Name, Language.Options_MaxLinesToShow_Description, ref Mutable.MaxLinesToRender)) + ImGui.SetNextItemWidth(200f * ImGuiHelpers.GlobalScale); + if (ImGui.InputInt(Language.Options_MaxLinesToShow_Name, ref Mutable.MaxLinesToRender)) { Mutable.MaxLinesToRender = Math.Clamp(Mutable.MaxLinesToRender, 1, 10_000); } + ImGuiUtil.HelpMarker(Language.Options_MaxLinesToShow_Description); } } From 66450dd51821adef1ebfb464584bf8641dff2f46 Mon Sep 17 00:00:00 2001 From: JonKazama-Hellion Date: Sat, 2 May 2026 21:19:40 +0200 Subject: [PATCH 03/12] refactor(i18n): pull tabs and database tab names into hellionstrings Both tab classes were the last two settings tabs still pulling their display name from the upstream Language resource bundle. Move them into HellionStrings so all eight settings tabs share one i18n source. The unused Language.Options_*_Tab keys stay around for backwards compat with cherry-picked upstream tabs. --- ChatTwo/Resources/HellionStrings.Designer.cs | 2 ++ ChatTwo/Resources/HellionStrings.de.resx | 6 ++++++ ChatTwo/Resources/HellionStrings.resx | 6 ++++++ ChatTwo/Ui/SettingsTabs/Database.cs | 2 +- ChatTwo/Ui/SettingsTabs/Tabs.cs | 2 +- 5 files changed, 16 insertions(+), 2 deletions(-) diff --git a/ChatTwo/Resources/HellionStrings.Designer.cs b/ChatTwo/Resources/HellionStrings.Designer.cs index c4dd719..671109f 100644 --- a/ChatTwo/Resources/HellionStrings.Designer.cs +++ b/ChatTwo/Resources/HellionStrings.Designer.cs @@ -196,6 +196,8 @@ internal class HellionStrings internal static string Settings_Tab_Appearance => Get(nameof(Settings_Tab_Appearance)); internal static string Settings_Tab_Window => Get(nameof(Settings_Tab_Window)); internal static string Settings_Tab_Chat => Get(nameof(Settings_Tab_Chat)); + internal static string Settings_Tab_Tabs => Get(nameof(Settings_Tab_Tabs)); + internal static string Settings_Tab_Database => Get(nameof(Settings_Tab_Database)); internal static string Settings_Tab_Information => Get(nameof(Settings_Tab_Information)); // Hellion Chat — General-Tab section headings diff --git a/ChatTwo/Resources/HellionStrings.de.resx b/ChatTwo/Resources/HellionStrings.de.resx index 1f9b7f4..d82ef5a 100644 --- a/ChatTwo/Resources/HellionStrings.de.resx +++ b/ChatTwo/Resources/HellionStrings.de.resx @@ -445,6 +445,12 @@ Chat + + Kanäle + + + Datenbank + Über diff --git a/ChatTwo/Resources/HellionStrings.resx b/ChatTwo/Resources/HellionStrings.resx index 519f010..6617440 100644 --- a/ChatTwo/Resources/HellionStrings.resx +++ b/ChatTwo/Resources/HellionStrings.resx @@ -445,6 +445,12 @@ Chat + + Tabs + + + Database + Information diff --git a/ChatTwo/Ui/SettingsTabs/Database.cs b/ChatTwo/Ui/SettingsTabs/Database.cs index afa75e9..46e6b12 100755 --- a/ChatTwo/Ui/SettingsTabs/Database.cs +++ b/ChatTwo/Ui/SettingsTabs/Database.cs @@ -16,7 +16,7 @@ internal sealed class Database : ISettingsTab private Plugin Plugin { get; } private Configuration Mutable { get; } - public string Name => Language.Options_Database_Tab + "###tabs-database"; + public string Name => HellionStrings.Settings_Tab_Database + "###tabs-database"; internal Database(Plugin plugin, Configuration mutable) { diff --git a/ChatTwo/Ui/SettingsTabs/Tabs.cs b/ChatTwo/Ui/SettingsTabs/Tabs.cs index 518644f..1f4b7ee 100755 --- a/ChatTwo/Ui/SettingsTabs/Tabs.cs +++ b/ChatTwo/Ui/SettingsTabs/Tabs.cs @@ -13,7 +13,7 @@ internal sealed class Tabs : ISettingsTab private Plugin Plugin { get; } private Configuration Mutable { get; } - public string Name => Language.Options_Tabs_Tab + "###tabs-tabs"; + public string Name => HellionStrings.Settings_Tab_Tabs + "###tabs-tabs"; private int ToOpen = -2; From ddd72a878ee0992687159d5ee0023c29e551acc6 Mon Sep 17 00:00:00 2001 From: JonKazama-Hellion Date: Sat, 2 May 2026 21:20:16 +0200 Subject: [PATCH 04/12] refactor(emotes): drop async void on LoadData async void Task.Run() is a no-op for awaiting purposes since the void returns immediately. Switch LoadData to async Task and have the two callers fire-and-forget the task directly. Exceptions still go through the existing try/catch inside LoadData. --- ChatTwo/EmoteCache.cs | 2 +- ChatTwo/Plugin.cs | 2 +- ChatTwo/Ui/Settings.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ChatTwo/EmoteCache.cs b/ChatTwo/EmoteCache.cs index 366220d..a2c58bf 100644 --- a/ChatTwo/EmoteCache.cs +++ b/ChatTwo/EmoteCache.cs @@ -66,7 +66,7 @@ public static class EmoteCache public static string[] SortedCodeArray = []; - public static async void LoadData() + public static async Task LoadData() { if (State is not LoadingState.Unloaded) return; diff --git a/ChatTwo/Plugin.cs b/ChatTwo/Plugin.cs index fc45fc1..9709d0a 100755 --- a/ChatTwo/Plugin.cs +++ b/ChatTwo/Plugin.cs @@ -241,7 +241,7 @@ public sealed class Plugin : IDalamudPlugin Interface.UiBuilder.OpenMainUi += OpenMainUi; if (Config.ShowEmotes) - Task.Run(EmoteCache.LoadData); + _ = EmoteCache.LoadData(); // Fire-and-forget intentional, exceptions are caught inside #if !DEBUG // Avoid 300ms hitch when sending first message by preloading the diff --git a/ChatTwo/Ui/Settings.cs b/ChatTwo/Ui/Settings.cs index cd7ddc7..41b70c9 100755 --- a/ChatTwo/Ui/Settings.cs +++ b/ChatTwo/Ui/Settings.cs @@ -177,7 +177,7 @@ public sealed class SettingsWindow : Dalamud.Interface.Windowing.Window GameFunctions.GameFunctions.SetChatInteractable(true); if (Plugin.Config.ShowEmotes) - Task.Run(EmoteCache.LoadData); + _ = EmoteCache.LoadData(); // Fire-and-forget intentional, exceptions are caught inside Initialise(); } From 4d977d51181fe2fafcbd557213df7e7fa1146cb5 Mon Sep 17 00:00:00 2001 From: JonKazama-Hellion Date: Sat, 2 May 2026 21:20:49 +0200 Subject: [PATCH 05/12] fix(fonts): marshal font chooser results onto the framework thread Three FontChooser ContinueWith handlers wrote Mutable.* directly from the threadpool. Wrap the result-write in Plugin.Framework.Run so the mutation lands on the same thread that owns the rest of the UI state. Matches the marshalling pattern already used by Database.cs and Privacy.cs background work. --- ChatTwo/Ui/SettingsTabs/Appearance.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/ChatTwo/Ui/SettingsTabs/Appearance.cs b/ChatTwo/Ui/SettingsTabs/Appearance.cs index e15e10b..a4bd4eb 100644 --- a/ChatTwo/Ui/SettingsTabs/Appearance.cs +++ b/ChatTwo/Ui/SettingsTabs/Appearance.cs @@ -147,7 +147,9 @@ internal sealed class Appearance : ISettingsTab globalChooser?.ResultTask.ContinueWith(r => { if (r.IsCompletedSuccessfully) - Mutable.GlobalFontV2 = r.Result; + { + Plugin.Framework.Run(() => Mutable.GlobalFontV2 = r.Result); + } }); ImGui.SameLine(); if (ImGui.Button("Reset##global")) @@ -164,7 +166,9 @@ internal sealed class Appearance : ISettingsTab japaneseChooser?.ResultTask.ContinueWith(r => { if (r.IsCompletedSuccessfully) - Mutable.JapaneseFontV2 = r.Result; + { + Plugin.Framework.Run(() => Mutable.JapaneseFontV2 = r.Result); + } }); ImGui.SameLine(); if (ImGui.Button("Reset##japanese")) @@ -179,7 +183,9 @@ internal sealed class Appearance : ISettingsTab italicChooser?.ResultTask.ContinueWith(r => { if (r.IsCompletedSuccessfully) - Mutable.ItalicFontV2 = r.Result; + { + Plugin.Framework.Run(() => Mutable.ItalicFontV2 = r.Result); + } }); ImGui.SameLine(); if (ImGui.Button("Reset##italic")) From 7c52e890e6adb796e42934b4b74c34773515b259 Mon Sep 17 00:00:00 2001 From: JonKazama-Hellion Date: Sat, 2 May 2026 21:22:12 +0200 Subject: [PATCH 06/12] feat(privacy): mark cleanup preview as stale when whitelist changes The preview block caches the deletion estimate from the last refresh. When the user toggles whitelist channels afterwards the cached number no longer reflects the current selection. Snapshot the whitelist on refresh and detect drift on every frame; on drift, grey out the counts and surface a stale hint plus an emphasised refresh button. Sits alongside the existing Cleanup_Help_SavedNote, which warns about a different mismatch (mutable vs saved) and stays as-is. --- ChatTwo/Resources/HellionStrings.Designer.cs | 1 + ChatTwo/Resources/HellionStrings.de.resx | 3 ++ ChatTwo/Resources/HellionStrings.resx | 3 ++ ChatTwo/Ui/SettingsTabs/Privacy.cs | 43 ++++++++++++++++++-- 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/ChatTwo/Resources/HellionStrings.Designer.cs b/ChatTwo/Resources/HellionStrings.Designer.cs index 671109f..61ead5b 100644 --- a/ChatTwo/Resources/HellionStrings.Designer.cs +++ b/ChatTwo/Resources/HellionStrings.Designer.cs @@ -64,6 +64,7 @@ internal class HellionStrings internal static string Cleanup_Heading => Get(nameof(Cleanup_Heading)); internal static string Cleanup_Help_Intro => Get(nameof(Cleanup_Help_Intro)); internal static string Cleanup_Help_SavedNote => Get(nameof(Cleanup_Help_SavedNote)); + internal static string Cleanup_Preview_Stale => Get(nameof(Cleanup_Preview_Stale)); internal static string Retention_Help_SavedNote => Get(nameof(Retention_Help_SavedNote)); internal static string Cleanup_RefreshPreview => Get(nameof(Cleanup_RefreshPreview)); internal static string Cleanup_NoPreview => Get(nameof(Cleanup_NoPreview)); diff --git a/ChatTwo/Resources/HellionStrings.de.resx b/ChatTwo/Resources/HellionStrings.de.resx index d82ef5a..2a586ae 100644 --- a/ChatTwo/Resources/HellionStrings.de.resx +++ b/ChatTwo/Resources/HellionStrings.de.resx @@ -81,6 +81,9 @@ Der manuelle Lauf nutzt deine GESPEICHERTE Retention-Policy, nicht die Slider-Werte oben. Klicke zuerst Speichern, wenn der Lauf deine aktuellen Änderungen anwenden soll. + + Vorschau veraltet, deine Whitelist hat sich seit dem letzten Aktualisieren geändert. Klicke Aktualisieren, um neu zu berechnen. + Vorschau aktualisieren diff --git a/ChatTwo/Resources/HellionStrings.resx b/ChatTwo/Resources/HellionStrings.resx index 6617440..817c3c7 100644 --- a/ChatTwo/Resources/HellionStrings.resx +++ b/ChatTwo/Resources/HellionStrings.resx @@ -81,6 +81,9 @@ The manual sweep uses your SAVED retention policy, not the slider values above. Click Save first if you want the run to apply your current edits. + + Preview is out of date — your whitelist has changed since the last refresh. Click Refresh to recalculate. + Refresh preview diff --git a/ChatTwo/Ui/SettingsTabs/Privacy.cs b/ChatTwo/Ui/SettingsTabs/Privacy.cs index eb3bea3..6126230 100644 --- a/ChatTwo/Ui/SettingsTabs/Privacy.cs +++ b/ChatTwo/Ui/SettingsTabs/Privacy.cs @@ -3,6 +3,7 @@ using ChatTwo.Export; using ChatTwo.Privacy; using ChatTwo.Resources; using ChatTwo.Util; +using Dalamud.Interface.Colors; using Dalamud.Interface.ImGuiNotification; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; @@ -55,6 +56,8 @@ internal sealed class Privacy : ISettingsTab private long CleanupKeepCount; private long CleanupDeleteCount; private bool CleanupRunning; + private bool CleanupPreviewStale; + private HashSet? CleanupPreviewSnapshot; // The retention-running state lives on Plugin so the auto-sweep and // this manual button see the same flag. UI reads stay lock-free @@ -484,6 +487,21 @@ internal sealed class Privacy : ISettingsTab ImGui.Spacing(); + // Drift-detection between the snapshot taken at last refresh + // and the current Mutable whitelist. Cleanup itself runs on + // the SAVED policy (Cleanup_Help_SavedNote covers that), but + // the user usually expects "the preview reflects what I just + // ticked" — so we surface the divergence instead of silently + // showing stale numbers. + if (CleanupPreviewSnapshot is not null + && !CleanupPreviewSnapshot.SetEquals(Mutable.PrivacyPersistChannels)) + { + CleanupPreviewStale = true; + } + + using (var emphasis = CleanupPreviewStale + ? ImRaii.PushColor(ImGuiCol.Button, ImGuiColors.HealerGreen with { W = 0.6f }) + : null) using (ImRaii.Disabled(CleanupRunning)) { if (ImGui.Button(HellionStrings.Cleanup_RefreshPreview)) @@ -496,10 +514,22 @@ internal sealed class Privacy : ISettingsTab return; } + if (CleanupPreviewStale) + { + ImGui.Spacing(); + ImGuiUtil.HelpText(HellionStrings.Cleanup_Preview_Stale); + } + ImGui.Spacing(); - ImGuiUtil.HelpText(string.Format(HellionStrings.Cleanup_TotalStored, CleanupKeepCount + CleanupDeleteCount)); - ImGuiUtil.HelpText(string.Format(HellionStrings.Cleanup_WillKeep, CleanupKeepCount)); - ImGuiUtil.HelpText(string.Format(HellionStrings.Cleanup_WillDelete, CleanupDeleteCount)); + + using (var staleColor = CleanupPreviewStale + ? ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudGrey) + : null) + { + ImGuiUtil.HelpText(string.Format(HellionStrings.Cleanup_TotalStored, CleanupKeepCount + CleanupDeleteCount)); + ImGuiUtil.HelpText(string.Format(HellionStrings.Cleanup_WillKeep, CleanupKeepCount)); + ImGuiUtil.HelpText(string.Format(HellionStrings.Cleanup_WillDelete, CleanupDeleteCount)); + } using (var tree = ImRaii.TreeNode(HellionStrings.Cleanup_Breakdown)) { @@ -555,6 +585,13 @@ internal sealed class Privacy : ISettingsTab else CleanupDeleteCount += count; } + + // Snapshot the whitelist as it stood at preview-time so the + // render pass can flag the user about subsequent edits. Only + // updated on success — if the preview throws, the previous + // snapshot stays in place so stale-detection keeps working. + CleanupPreviewSnapshot = new HashSet(Mutable.PrivacyPersistChannels); + CleanupPreviewStale = false; } catch (Exception e) { From 3fc42963aef9f683cd0cc5e95165336d8a7195c5 Mon Sep 17 00:00:00 2001 From: JonKazama-Hellion Date: Sat, 2 May 2026 21:22:36 +0200 Subject: [PATCH 07/12] feat(autotell): dim selection background of greeted tabs The text-disabled colour alone made greeted tabs visually weak in the sidebar. Push dimmed Header and HeaderHovered values alongside the existing Text push so the selected and hovered states match the greeted state too. Idle state stays untouched because ImGui Selectable has no idle background slot. --- ChatTwo/Ui/ChatLogWindow.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/ChatTwo/Ui/ChatLogWindow.cs b/ChatTwo/Ui/ChatLogWindow.cs index 1e60ba8..a9559c3 100644 --- a/ChatTwo/Ui/ChatLogWindow.cs +++ b/ChatTwo/Ui/ChatLogWindow.cs @@ -1374,8 +1374,18 @@ public sealed class ChatLogWindow : Window { // Dim the tab name once the user marked the partner // as greeted, so a glance at the sidebar tells them - // who still needs attention. + // who still needs attention. Selectable has no idle + // background slot in ImGui, so the dim only applies + // to the selected and hovered states — the text dim + // alone signals greeted in the idle state. + var headerBase = ImGui.GetColorU32(ImGuiCol.Header); + var hoverBase = ImGui.GetColorU32(ImGuiCol.HeaderHovered); + var dimHeader = (headerBase & 0xFF000000u) | ((headerBase & 0x00FEFEFEu) >> 1); + var dimHover = (hoverBase & 0xFF000000u) | ((hoverBase & 0x00FEFEFEu) >> 1); + using (ImRaii.PushColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.TextDisabled))) + using (ImRaii.PushColor(ImGuiCol.Header, dimHeader)) + using (ImRaii.PushColor(ImGuiCol.HeaderHovered, dimHover)) { clicked = ImGui.Selectable(selectableLabel, isCurrentTab); } From e4593a0fdae4062838b89688f246f710da64a1a3 Mon Sep 17 00:00:00 2001 From: JonKazama-Hellion Date: Sat, 2 May 2026 21:23:17 +0200 Subject: [PATCH 08/12] refactor(db): add BindIntList helper for parameterised IN-clauses Centralised builder for dynamic IN-clauses that binds each value as a named parameter and returns the comma-joined placeholder string. Used by the upcoming MessageStore migrations away from string-interpolated SQL. --- ChatTwo/MessageStore.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/ChatTwo/MessageStore.cs b/ChatTwo/MessageStore.cs index 5f6b565..c527449 100644 --- a/ChatTwo/MessageStore.cs +++ b/ChatTwo/MessageStore.cs @@ -806,6 +806,24 @@ internal class MessageStore : IDisposable return new MessageEnumerator(cmd.ExecuteReader()); } + + // Build "$prefix0,$prefix1,..." placeholder list and bind values to + // the command. SQLite has no native array parameter, so we generate + // the list at runtime and bind each entry under its own name. Used + // for IN-clauses and similar dynamic-arity SQL fragments. + private static string BindIntList(SqliteCommand cmd, string prefix, IEnumerable values) + { + var names = new List(); + var index = 0; + foreach (var value in values) + { + var name = $"${prefix}{index}"; + cmd.Parameters.AddWithValue(name, value); + names.Add(name); + index++; + } + return string.Join(",", names); + } } internal class MessageEnumerator(DbDataReader reader) : IEnumerable, IDisposable, IAsyncDisposable From 12085ff1e22b3036e8d9113d3bf3876ddbd18e5c Mon Sep 17 00:00:00 2001 From: JonKazama-Hellion Date: Sat, 2 May 2026 21:24:36 +0200 Subject: [PATCH 09/12] refactor(db): parameterise IN-clause SQL via BindIntList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate CleanupRetainOnly, StreamForExport, CountDateRange, GetDateRange and GetPagedDateRange from interpolated IN lists onto BindIntList. Eliminates the string-interpolation pattern for SQL value lists in the IN-clause sites. Behavioural diff against v0.5.0: none — same enum/byte values, just bound under named parameters instead of inlined. --- ChatTwo/MessageStore.cs | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/ChatTwo/MessageStore.cs b/ChatTwo/MessageStore.cs index c527449..72b2623 100644 --- a/ChatTwo/MessageStore.cs +++ b/ChatTwo/MessageStore.cs @@ -395,11 +395,11 @@ internal class MessageStore : IDisposable throw new InvalidOperationException("CleanupRetainOnly requires at least one allowed ChatType. Use ClearMessages for a full wipe."); } - var inList = string.Join(",", allowedTypes); long deleted; using (var cmd = Connection.CreateCommand()) { - cmd.CommandText = $"DELETE FROM messages WHERE ChatType NOT IN ({inList});"; + var placeholders = BindIntList(cmd, "ct", allowedTypes); + cmd.CommandText = $"DELETE FROM messages WHERE ChatType NOT IN ({placeholders});"; cmd.CommandTimeout = 600; deleted = cmd.ExecuteNonQuery(); } @@ -512,15 +512,16 @@ internal class MessageStore : IDisposable DateTimeOffset? from, DateTimeOffset? to) { + var cmd = Connection.CreateCommand(); + var clauses = new List { "deleted = false" }; if (chatTypes is { Count: > 0 }) - clauses.Add($"ChatType IN ({string.Join(",", chatTypes)})"); + clauses.Add($"ChatType IN ({BindIntList(cmd, "exct", chatTypes)})"); if (from is not null) clauses.Add("Date >= $From"); if (to is not null) clauses.Add("Date <= $To"); - var cmd = Connection.CreateCommand(); cmd.CommandText = @" SELECT Id, @@ -693,16 +694,17 @@ internal class MessageStore : IDisposable internal long CountDateRange(DateTime after, DateTime before, IEnumerable channels, ulong? receiver = null) { + using var cmd = Connection.CreateCommand(); + List whereClauses = ["deleted = false"]; if (receiver != null) whereClauses.Add("Receiver = $Receiver"); whereClauses.Add("Date BETWEEN $After AND $Before"); - whereClauses.Add($"ChatType IN ({string.Join(", ", channels)})"); + whereClauses.Add($"ChatType IN ({BindIntList(cmd, "cdr", channels.Select(c => (int)c))})"); var whereClause = "WHERE " + string.Join(" AND ", whereClauses); - using var cmd = Connection.CreateCommand(); // Select last N messages by date DESC, but reverse the order to get // them in ascending order. cmd.CommandText = @" @@ -722,16 +724,17 @@ internal class MessageStore : IDisposable internal MessageEnumerator GetDateRange(DateTime after, DateTime before, IEnumerable channels, ulong? receiver = null) { + var cmd = Connection.CreateCommand(); + List whereClauses = ["deleted = false"]; if (receiver != null) whereClauses.Add("Receiver = $Receiver"); whereClauses.Add("Date BETWEEN $After AND $Before"); - whereClauses.Add($"ChatType IN ({string.Join(", ", channels)})"); + whereClauses.Add($"ChatType IN ({BindIntList(cmd, "gdr", channels.Select(c => (int)c))})"); var whereClause = $"WHERE {string.Join(" AND ", whereClauses)}"; - var cmd = Connection.CreateCommand(); // Select last N messages by date DESC, but reverse the order to get // them in ascending order. cmd.CommandText = @" @@ -763,16 +766,17 @@ internal class MessageStore : IDisposable internal MessageEnumerator GetPagedDateRange(DateTime after, DateTime before, IEnumerable channels, ulong? receiver = null, int page = 0) { + var cmd = Connection.CreateCommand(); + List whereClauses = ["deleted = false"]; if (receiver != null) whereClauses.Add("Receiver = $Receiver"); whereClauses.Add("Date BETWEEN $After AND $Before"); - whereClauses.Add($"ChatType IN ({string.Join(", ", channels)})"); + whereClauses.Add($"ChatType IN ({BindIntList(cmd, "pdr", channels.Select(c => (int)c))})"); var whereClause = $"WHERE {string.Join(" AND ", whereClauses)}"; - var cmd = Connection.CreateCommand(); // Select last N messages by date DESC, but reverse the order to get // them in ascending order. cmd.CommandText = @" From 303729f3d3adca556f2a5f0fb086957a9c5fb78d Mon Sep 17 00:00:00 2001 From: JonKazama-Hellion Date: Sat, 2 May 2026 21:25:05 +0200 Subject: [PATCH 10/12] refactor(db): parameterise DeleteByRetentionPolicy SQL clauses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-channel WHERE tuples and the catch-all default-clause now bind ChatType and cutoff via named parameters instead of being inlined as literals. Combines BindIntList for the explicit-types exclusion with explicit AddWithValue for each (type, cutoff) tuple. Behavioural diff against v0.5.0: none — same retention windows, same cutoff math, just parameterised. --- ChatTwo/MessageStore.cs | 51 ++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/ChatTwo/MessageStore.cs b/ChatTwo/MessageStore.cs index 72b2623..4aa5904 100644 --- a/ChatTwo/MessageStore.cs +++ b/ChatTwo/MessageStore.cs @@ -346,31 +346,44 @@ internal class MessageStore : IDisposable throw new ArgumentOutOfRangeException(nameof(chatTypeDaysMap), "Negative retention is not allowed."); var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - var clauses = new List(); - foreach (var (type, days) in chatTypeDaysMap) - { - var cutoff = nowMs - days * 86400000L; - clauses.Add($"(ChatType = {type} AND Date < {cutoff})"); - } - // Catch-all for channels without an explicit override. "0" is treated - // as "do not delete by default" — without an explicit user override, - // unmapped channels stay forever instead of getting wiped immediately. - if (defaultDays > 0) - { - var cutoff = nowMs - defaultDays * 86400000L; - var explicitTypes = chatTypeDaysMap.Count > 0 - ? string.Join(",", chatTypeDaysMap.Keys) - : "-1"; // empty list would produce invalid SQL - clauses.Add($"(ChatType NOT IN ({explicitTypes}) AND Date < {cutoff})"); - } - - if (clauses.Count == 0) + if (chatTypeDaysMap.Count == 0 && defaultDays <= 0) return 0; long deleted; using (var cmd = Connection.CreateCommand()) { + var clauses = new List(); + var index = 0; + foreach (var (type, days) in chatTypeDaysMap) + { + var cutoff = nowMs - days * 86400000L; + var typeParam = $"$type{index}"; + var cutoffParam = $"$cutoff{index}"; + cmd.Parameters.AddWithValue(typeParam, type); + cmd.Parameters.AddWithValue(cutoffParam, cutoff); + clauses.Add($"(ChatType = {typeParam} AND Date < {cutoffParam})"); + index++; + } + + // Catch-all for channels without an explicit override. "0" is + // treated as "do not delete by default" — without an explicit + // user override, unmapped channels stay forever instead of + // getting wiped immediately. + if (defaultDays > 0) + { + var defaultCutoff = nowMs - defaultDays * 86400000L; + cmd.Parameters.AddWithValue("$defaultCutoff", defaultCutoff); + + var explicitPlaceholders = chatTypeDaysMap.Count > 0 + ? BindIntList(cmd, "explicit", chatTypeDaysMap.Keys) + : "-1"; // empty list would produce invalid SQL + clauses.Add($"(ChatType NOT IN ({explicitPlaceholders}) AND Date < $defaultCutoff)"); + } + + if (clauses.Count == 0) + return 0; + cmd.CommandText = $"DELETE FROM messages WHERE {string.Join(" OR ", clauses)};"; cmd.CommandTimeout = 600; deleted = cmd.ExecuteNonQuery(); From 3584c945233d608daaf592eca66feaa04f622af9 Mon Sep 17 00:00:00 2001 From: JonKazama-Hellion Date: Sat, 2 May 2026 21:25:40 +0200 Subject: [PATCH 11/12] docs(db): explain why pragma statements stay interpolated Both PRAGMA call sites take values that SQLite does not accept as bound parameters. ColumnExists takes a hardcoded table name, the migration call takes a compile-time int from the version sequence. Comments now state both facts so future readers don't try to wedge a defensive whitelist into a path that cannot be reached from anywhere user-controlled. --- ChatTwo/MessageStore.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ChatTwo/MessageStore.cs b/ChatTwo/MessageStore.cs index 4aa5904..988367a 100644 --- a/ChatTwo/MessageStore.cs +++ b/ChatTwo/MessageStore.cs @@ -239,6 +239,9 @@ internal class MessageStore : IDisposable private bool ColumnExists(string table, string column) { + // PRAGMA does not accept SQLite parameter bindings. The table name is + // a compile-time constant fed in from internal call sites, so the + // interpolation cannot be reached from any user-controlled path. using var cmd = Connection.CreateCommand(); cmd.CommandText = $"PRAGMA table_info({table});"; using var reader = cmd.ExecuteReader(); @@ -298,8 +301,10 @@ internal class MessageStore : IDisposable { Plugin.Log.Information($"Setting version {version}"); using var cmd = Connection.CreateCommand(); - // Parameters aren't supported for PRAGMA queries, and you can't set the - // version with a pragma_ function. + // PRAGMA does not accept SQLite parameter bindings, and there is no + // pragma_ function variant that can set the version either. The + // version is a compile-time int from the migration sequence, never + // user input. cmd.CommandText = $"PRAGMA user_version = {version};"; cmd.ExecuteNonQuery(); } From 4ba5004322ee9667303ab919a5e9eed9b45e014d Mon Sep 17 00:00:00 2001 From: JonKazama-Hellion Date: Sat, 2 May 2026 21:27:10 +0200 Subject: [PATCH 12/12] chore(release): bump version to 0.5.1 Hardening and polish patch. Eight backlog items from the v0.5.0 codebase review. No new features, no migration. --- ChatTwo/ChatTwo.csproj | 2 +- ChatTwo/HellionChat.yaml | 30 ++++++++++++++++++++++++++++++ repo.json | 12 ++++++------ 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/ChatTwo/ChatTwo.csproj b/ChatTwo/ChatTwo.csproj index 4a6c618..ed550d7 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.5.0 + 0.5.1 enable