diff --git a/HellionChat/AutoTellTabsService.cs b/HellionChat/AutoTellTabsService.cs index 7ee6456..8bc3436 100644 --- a/HellionChat/AutoTellTabsService.cs +++ b/HellionChat/AutoTellTabsService.cs @@ -10,14 +10,8 @@ using HellionChat.Util; namespace HellionChat; -// 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). +// Auto-Tell-Tabs: spawns session-only tabs per /tell partner. +// Subscribes to MessageManager.MessageProcessed and ClientState.Logout. internal sealed class AutoTellTabsService : IDisposable { private readonly Plugin _plugin; @@ -87,10 +81,7 @@ internal sealed class AutoTellTabsService : IDisposable 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. + // Diagnostics: helps detect regressions (FFXIV payload changes, new edge cases) Plugin.Log.Warning( $"[AutoTellTabs] Could not extract tell partner. type={message.Code.Type}, " + $"senderChunks={message.Sender.Count}, contentChunks={message.Content.Count}, " @@ -105,9 +96,7 @@ internal sealed class AutoTellTabsService : IDisposable 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). + // Already routed via MessageManager pipeline return; } @@ -124,10 +113,7 @@ internal sealed class AutoTellTabsService : IDisposable { 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. + // Sender is the partner; check chunks first, then raw SeString as fallback var fromSender = ChunkUtil.TryGetPlayerPayload(message.Sender) ?? ChunkUtil.TryGetPlayerPayload(message.SenderSource); @@ -138,10 +124,7 @@ internal sealed class AutoTellTabsService : IDisposable 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. + // Outgoing tell: check content first, then channels's TellTarget as fallback var fromContent = ChunkUtil.TryGetPlayerPayload(message.Content) ?? ChunkUtil.TryGetPlayerPayload(message.ContentSource) @@ -175,10 +158,7 @@ internal sealed class AutoTellTabsService : IDisposable 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. + // Prioritize greeted tabs for drop; within each bucket, drop by oldest LastActivity var victim = Plugin .Config.Tabs.Select((tab, idx) => (Tab: tab, Index: idx)) .Where(t => t.Tab.IsTempTab) @@ -191,12 +171,7 @@ internal sealed class AutoTellTabsService : IDisposable return; } - // v0.6.1 — if the victim is currently popped out, tear down the - // matching Popout window first. Otherwise the window stays in - // PopOutWindows + WindowSystem and renders empty / re-spawns on the - // next AddPopOutsToDraw tick. Latent since pop-outs were introduced; - // becomes visible with AutoTellTabsOpenAsPopout where dropping a - // popped tab is now a routine code path. + // Clean up pop-out window if tab is popped out if (victim.Tab.PopOut) { var popout = _plugin.ChatLogWindow.ActivePopouts.FirstOrDefault(p => @@ -210,8 +185,7 @@ internal sealed class AutoTellTabsService : IDisposable 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. + // Re-anchor active tab to avoid silent switch when tab is dropped if (victim.Index <= _plugin.LastTab) { _plugin.WantedTab = 0; @@ -222,22 +196,12 @@ internal sealed class AutoTellTabsService : IDisposable { 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. - // The current message is already persisted in the store by the - // time MessageProcessed fires (see MessageManager.cs: UpsertMessage - // runs before the event), so we have to exclude it explicitly to - // avoid the separator landing below the live tell. + // Preload history: chronological order with current message already persisted PreloadHistory(tab, partner.Name, partner.World, currentMessage.Id); tab.AddMessage(currentMessage, unread: true); - // Hellion Chat v0.6.1 — opt-in: open new /tell tabs directly as a - // pop-out window. Set BEFORE Tabs.Add so the next render-tick's - // AddPopOutsToDraw() sees PopOut=true and spawns the Popout window - // alongside the tab going into the list. No SaveConfig() because - // auto-tell tabs are IsTempTab (session-only, never persisted). + // Open as pop-out if configured (set before Tabs.Add for next render-tick) if (Plugin.Config.AutoTellTabsOpenAsPopout) { tab.PopOut = true; @@ -272,9 +236,7 @@ internal sealed class AutoTellTabsService : IDisposable { 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. + // Fallback if world lookup misses (rare; only for unseen worlds) return $"{playerName}@World{worldRowId}"; } @@ -288,9 +250,7 @@ internal sealed class AutoTellTabsService : IDisposable try { - // Pull one extra row because the live tell that triggered this - // spawn is already in the store and would otherwise eat one of - // the user's preload-budget slots. + // Pull one extra row: current message is already in store and would eat a preload slot var history = _store.GetTellHistoryWithSender( _messageManager.CurrentContentId, senderName, @@ -305,23 +265,17 @@ internal sealed class AutoTellTabsService : IDisposable if (historicMessages.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. + // No prior tells; leave tab empty to avoid orphaned "history loaded" marker 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. + // History is oldest-first; add in order for chronological display foreach (var message in historicMessages) { 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. + // Separator between history and live tell (sorts after history but before current) tab.Messages.AddPrune( MakeSystemMarker(HellionStrings.AutoTellTabs_HistorySeparator), MessageManager.MessageDisplayLimit @@ -329,9 +283,7 @@ internal sealed class AutoTellTabsService : IDisposable } 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. + // Non-fatal: tab still spawns with visible error notice instead of silent history loss Plugin.Log.Error(ex, "[AutoTellTabs] History preload failed"); tab.Messages.AddPrune( MakeSystemMarker(HellionStrings.AutoTellTabs_HistoryLoadError), @@ -372,9 +324,7 @@ internal sealed class AutoTellTabsService : IDisposable 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. + // Guard against frame-race: sidebar might render a tab already removed by LRU or logout if (!Plugin.Config.Tabs.Contains(tab)) { return; @@ -388,18 +338,12 @@ internal sealed class AutoTellTabsService : IDisposable { lock (_tempTabsLock) { - // Snapshot whether the active tab is about to be removed, BEFORE - // we mutate the list — index lookups would lie to us afterwards. + // Snapshot active tab index before mutating list var lastIndex = _plugin.LastTab; var lastIndexValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count; var currentWasTempTab = lastIndexValid && Plugin.Config.Tabs[lastIndex].IsTempTab; - // v0.6.1 — symmetric to DropOldestTempTab cleanup: tear down any - // popped-out temp tab windows before removing the tabs themselves, - // otherwise PopOutWindows + WindowSystem keep ghost entries until - // the next plugin reload. Especially relevant once Auto-Pop-Out is - // enabled — every logout would otherwise leak as many ghosts as - // there were active /tell pop-outs. + // Clean up pop-out windows before removing temp tabs var poppedTempTabIds = Plugin .Config.Tabs.Where(t => t.IsTempTab && t.PopOut) .Select(t => t.Identifier) @@ -419,9 +363,7 @@ internal sealed class AutoTellTabsService : IDisposable 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. + // Force switch to tab 0 if active tab was temp or index is now out of range var stillValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count; if (currentWasTempTab || !stillValid) { diff --git a/HellionChat/Branding/BrandingLinks.cs b/HellionChat/Branding/BrandingLinks.cs index 45adc57..c6eec3c 100644 --- a/HellionChat/Branding/BrandingLinks.cs +++ b/HellionChat/Branding/BrandingLinks.cs @@ -1,11 +1,12 @@ -// HellionChat/Branding/BrandingLinks.cs namespace HellionChat.Branding; -// Centralised so a future invite rotation only touches one file. The same -// link is currently hard-coded in repo.json, README.md, SUPPORT.md, -// CONTRIBUTORS.md and HellionChat.yaml — those will be migrated to consume -// this constant in a separate housekeeping sweep +// Centralised — a future invite/URL rotation only touches this file. internal static class BrandingLinks { public const string HellionForgeDiscordInvite = "https://discord.gg/X9V7Kcv5gR"; + public const string HellionForgeGitea = "https://gitea.hellion-forge.cloud/Hellion-Forge"; + public const string HellionChatRepo = + "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat"; + public const string HellionForgeWebsite = "https://hellion-forge.cloud"; + public const string HellionMediaWebsite = "https://hellion-media.de/de"; } diff --git a/HellionChat/Chunk.cs b/HellionChat/Chunk.cs index 9f943d0..042df73 100755 --- a/HellionChat/Chunk.cs +++ b/HellionChat/Chunk.cs @@ -34,9 +34,7 @@ public abstract class Chunk _ => null, }; - /// - /// Get some basic text for use in generating hashes. - /// + // Returns basic text for hashing (content for TextChunk, icon name for IconChunk) internal string StringValue() { return this switch @@ -108,9 +106,6 @@ public class TextChunk : Chunk Content = content ?? ""; } - /// - /// Creates a new TextChunk with identical styling to this one. - /// public TextChunk NewWithStyle(ChunkSource source, Payload? link, string content) { return new TextChunk(source, link, content) @@ -122,9 +117,6 @@ public class TextChunk : Chunk }; } - /// - /// Creates a new TextChunk with identical styling to this one. - /// public TextChunk NewWithStyle(Chunk chunk, string content) { return new TextChunk(chunk, content) diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index 0465041..5efebfd 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -38,33 +38,26 @@ public class Configuration : IPluginConfiguration public int Version { get; set; } = LatestVersion; - // v1.1.0 — Theme-Engine. Slug-basiert; ThemeRegistry liefert das Objekt. + // Slug-based; ThemeRegistry resolves the object at runtime. public string Theme = "hellion-arctic"; - // v1.1.0 — Globale Window-Opacity, theme-übergreifend. Migration aus - // HellionThemeWindowOpacity beim Bump v13 → v14. + // Global window opacity, applied across all themes. public float WindowOpacity = 0.85f; - // v1.1.0 — Felder für künftige UI-Toggles (v1.2.0 / v1.3.0). Werden - // vorab angelegt, damit später keine Migration nötig ist. + // Reserved for future UI toggles; pre-declared to avoid a migration later. public bool ReduceMotion; - // v1.2.1 — Default geflippt von false → true. Card-Rows-Layout aus - // v1.2.0 wurde als zu dicht empfunden; Single-Line `[HH:mm] Sender: - // Text` ist besser lesbar und platzsparender. Bestand-User mit aktiv - // false werden durch die v15→v16-Migration auf den neuen Default - // gehoben (Heuristik: wer in v1.2.0 false hatte, hatte den damals - // neu eingeführten Default — kaum jemand hat aktiv abgeschaltet). + // v1.2.1: default flipped false → true. Compact single-line layout is + // more readable than the card-rows layout introduced in v1.2.0. public bool UseCompactDensity = true; - // Hellion Chat — Privacy filter (DSGVO Art. 25 Privacy by Default). - // Master-switch defaults to true; set false to restore upstream behavior. + // Privacy by Default master switch. Set false to restore upstream behaviour. public bool PrivacyFilterEnabled = true; // Empty set means the migration has not run yet — see Plugin.cs v6→v7. public HashSet PrivacyPersistChannels = []; - // Failsafe for ChatTypes added by future FFXIV patches we don't know about. + // Failsafe for ChatTypes added by future FFXIV patches. public bool PrivacyPersistUnknownChannels; public bool IsAllowedForStorage(ChatType type) @@ -76,79 +69,23 @@ public class Configuration : IPluginConfiguration return PrivacyPersistUnknownChannels; } - // Hellion Chat — Message retention (GDPR data minimization, time axis). - // Master switch defaults to false; the plugin will not delete history - // until the user explicitly opts in. + // Retention master switch defaults to false — plugin will not delete + // history until the user explicitly opts in. public bool RetentionEnabled; public int RetentionDefaultDays = 30; public Dictionary RetentionPerChannelDays = []; public DateTimeOffset RetentionLastRunAt = DateTimeOffset.MinValue; - - // Hellion Chat first-run wizard — opens once on a fresh install. Existing - // ChatTwo users skip it because the v6→v7 migration sets the flag. public bool FirstRunCompleted; - - // Use the bundled Exo 2 font (OFL-1.1) for the regular plugin font - // instead of whatever GlobalFontV2.FontId points at. Default ON so a - // fresh install gets the Hellion typography out-of-the-box; flip OFF - // to fall back to the user's chosen system or Dalamud font. public bool UseHellionFont = true; - - // Cycle 1 of the plugin-integration roadmap. When Honorific is installed - // and reports a custom title, render it in the chat header above the - // message log. Auto-hides regardless when Honorific is missing or the - // active title is original/empty, so leaving this on is safe even for - // users who don't run Honorific. public bool ShowHonorificTitleInHeader = true; - - // Hellion Chat — Auto-Tell-Tabs. When enabled, an incoming or outgoing - // /tell spawns a session-only tab dedicated to that conversation - // partner. See spec: Hellion Chat Auto-Tell-Tabs Spec (Obsidian). public bool EnableAutoTellTabs = true; - - // Hard cap on simultaneously open auto tell tabs. Range enforced by the - // settings slider (1–50). LRU drop favors greeted tabs first. public int AutoTellTabsLimit = 15; - - // When true the sidebar shows only a thin separator before the temp - // tabs; when false a section header "Active Tells (n)" is rendered. public bool AutoTellTabsCompactDisplay; - - // Number of prior tells to preload from the message store when an - // auto tell tab is spawned. Range 0–100; 0 disables preload. public int AutoTellTabsHistoryPreload = 20; - - // Show the greeter "marked-as-greeted" toggle button next to each - // temp tab and dim the tab name when set. Off by default because the - // workflow is specific to club-greeter use cases — most users just - // want the auto tabs themselves without the extra UI affordance. public bool AutoTellTabsShowGreetedToggle; - - // Hellion Chat — One-Time-Hint-Banner that introduces the v0.6.0 pop-out - // input feature. Set to true once the user dismisses the banner from a - // pop-out window; never reset after that. public bool SeenPopOutInputHint; - - // Hellion Chat — v0.6.0 master switch for the pop-out input bar. - // Global on purpose: per-tab makes no sense for Auto-Tell-Tabs which - // are session-only and would force the user to re-enable it for every - // new conversation. Default flipped to ON in v0.6.1 (was OFF in v0.6.0) - // because tester feedback called the manual toggle "umständlich, wirkt - // unfertig". v11 → v12 migration applies the same flip to existing users. public bool PopOutInputEnabled = true; - - // Hellion Chat — v0.6.1 One-Time-Hint-Banner that introduces the - // chat-header pop-out toolbar button and reminds about the pop-out - // input default flip. Set to true once the user dismisses the banner - // from the main chat window; never reset after that. public bool SeenPopOutHeaderHint; - - // Hellion Chat — v0.6.1 opt-in: when true, AutoTellTabsService.SpawnTempTab - // sets tab.PopOut = true on every new auto-tell tab so the conversation - // pops out as its own window directly. Closing the pop-out returns the - // tab to the sidebar via the standard Popout.OnClose() flow. Default OFF - // because the existing sidebar workflow is what most users (especially - // club greeters tracking many parallel tells) expect by default. public bool AutoTellTabsOpenAsPopout; public int GetRetentionDays(ChatType type) @@ -167,10 +104,7 @@ public class Configuration : IPluginConfiguration public bool HideInLoadingScreens; public bool HideInBattle; - // v1.2.1 — Default geflippt false → true. Hellion-UI im NG+-Menü - // versteckt zu halten ist konsistent mit den anderen Hide-Defaults - // (Cutscenes, Logged-out, UI-Hidden) — UI-out-of-the-way bei Story- - // Sequenzen. + // v1.2.1: default flipped false → true for consistency with other hide defaults. public bool HideInNewGamePlusMenu = true; public bool HideWhenInactive; public int InactivityHideTimeout = 10; @@ -186,18 +120,8 @@ public class Configuration : IPluginConfiguration public bool NativeItemTooltips = true; public bool PrettierTimestamps = true; public bool MoreCompactPretty; - - // v1.2.1 — Default geflippt false → true. Wiederholte Zeitstempel - // innerhalb derselben Minute lesen sich als Rauschen; ein einziger - // Timestamp pro Minute reicht aus um die Konversation zu verorten. public bool HideSameTimestamps = true; public bool ShowNoviceNetwork; - - // Hellion Chat — vertical sidebar tab layout reads better than the - // horizontal tab strip in the company of Auto-Tell-Tabs (a club - // greeter typically tracks 5–15 simultaneous conversations). Bestand - // users keep their saved value untouched — only fresh installs pick - // up the new default. public bool SidebarTabView = true; public bool PrintChangelog = true; public bool OnlyPreviewIf; @@ -218,22 +142,10 @@ public class Configuration : IPluginConfiguration public bool CollapseKeepUniqueLinks; public bool PlaySounds = true; public bool KeepInputFocus = true; - - // v1.2.1 — Default gesenkt 5000 → 2500. 5000 ist auf Mid-Range- - // Hardware bei langen Sessions spürbar langsamer (Card-Layout - // re-Layout pro Frame), 2500 deckt eine typische Stunde Chat ab - // und bleibt smooth. User die mehr brauchen können bis 10000 hoch. public int MaxLinesToRender = 2_500; // 1-10000 - - // Default ON to match a German / European 24h locale. The - // ChatLogWindow.cs format-flip in v0.5.1 honours this strictly via - // CultureInfo.InvariantCulture so the result is consistent across - // host locales. public bool Use24HourClock = true; - public bool ShowEmotes = true; public HashSet BlockedEmotes = []; - public bool FontsEnabled = true; public ExtraGlyphRanges ExtraGlyphRanges = 0; public float FontSizeV2 = 12.75f; @@ -258,12 +170,6 @@ public class Configuration : IPluginConfiguration public float TooltipOffset; - // v1.2.1 — Default-Chat-Farben sind das Hellion-Brand-Preset. Der - // First-Run-Wizard bietet keine Theme-/Preset-Wahl an, daher kriegen - // neue User die Hellion-Brand-Farben out-of-the-box (Cyan-Familie für - // Standard/Tell, Ember/Warning für laute Channels). Bestand-User mit - // leerem ChatColours-Dict werden durch die v15→v16-Migration auf das - // Preset gehoben; User die bereits Custom-Farben haben, bleiben. public Dictionary ChatColours = BuildDefaultChatColours(); private static Dictionary BuildDefaultChatColours() @@ -333,9 +239,7 @@ public class Configuration : IPluginConfiguration MaxLinesToRender = other.MaxLinesToRender; Use24HourClock = other.Use24HourClock; ShowEmotes = other.ShowEmotes; - // Deep-copy the set so the live and mutable Configuration instances don't share state - // — a HashSet reference assignment would cause edits in the settings window to leak - // into the live config before the user clicks Save. + // Deep-copy so settings window edits don't leak into live config before Save. BlockedEmotes = new HashSet(other.BlockedEmotes); FontsEnabled = other.FontsEnabled; ItalicEnabled = other.ItalicEnabled; @@ -349,22 +253,11 @@ public class Configuration : IPluginConfiguration ChatColours = other.ChatColours.ToDictionary(entry => entry.Key, entry => entry.Value); ColorSelectedInputChannelButton = other.ColorSelectedInputChannelButton; - // Hellion Chat — Auto-Tell-Tabs are session-only and therefore - // never present in a disk-loaded copy. Keep the live temp tabs of - // *this* configuration alive across an UpdateFrom so a settings - // save (or sidebar-mode toggle) does not silently destroy the - // user's open tell conversations. - // - // For persistent tabs we go through Tab.Clone() which intentionally - // does NOT copy the NonSerialized Messages list (avoids shared - // mutable state on disk-load). On a settings save that means the - // chat history for every persistent tab would be wiped — bug - // reported by Flo 2026-05-05. We work around it by capturing the - // live MessageList (and LastSendUnread counter) by Identifier - // before the replace, then restoring it onto the freshly cloned - // tabs whose Identifier survives Tab.Clone(). New tabs added in - // settings get a fresh empty MessageList; deleted tabs lose their - // history (intended). + // Keep live temp tabs alive across UpdateFrom — a settings save must + // not destroy open tell conversations. For persistent tabs, capture + // the live MessageList and LastSendUnread by Identifier before the + // replace and restore them onto the freshly cloned tabs; new tabs + // get an empty MessageList, deleted tabs lose their history (intended). var liveTempTabs = Tabs.Where(t => t.IsTempTab).ToList(); var livePersistentSession = Tabs.Where(t => !t.IsTempTab) .ToDictionary(t => t.Identifier, t => (t.Messages, t.LastSendUnread)); @@ -456,9 +349,7 @@ public class Tab { public string Name = Language.Tab_DefaultName; - // v1.2.0 — optionaler FontAwesome-Glyph-Name. Null bedeutet: - // Default-Mapping aus TabIconMapping greift (basiert auf Tab-Name). - // User können hier per Settings → Tabs einen eigenen Glyph setzen. + // Optional FontAwesome glyph name; null falls back to TabIconMapping default. public string? Icon = null; [Obsolete("Removed in favor of SelectedChannels")] @@ -510,15 +401,12 @@ public class Tab [NonSerialized] public Guid Identifier = Guid.NewGuid(); - // Hellion Chat — Auto-Tell-Tabs greeted flag. Toggled manually from the - // sidebar to mark a tell partner as already greeted in the current - // session. NonSerialized because the temp tab itself is session-only. + // Session-only greeted flag for club-greeter workflows. [NonSerialized] public bool IsGreeted; - // v1.4.2 — TabTintCache uses separate validation keys per cache so a - // TellTarget change picked up by GetTint can't strand GetIcon (or vice - // versa) with a stale entry that looks fresh on the shared key. + // Separate validation keys per cache so TellTarget changes don't + // cause GetTint and GetIcon to strand each other with stale entries. [NonSerialized] internal string? _cachedTintTellName; @@ -540,17 +428,12 @@ public class Tab public bool Matches(Message message) { if (!message.Matches(SelectedChannels, ExtraChatAll, ExtraChatChannels)) - { return false; - } - // Auto-tell temp tabs are bound to a single conversation partner; - // every other tell that matches the channel filter must NOT land - // here, otherwise all temp tabs would mirror "Tell Exclusive". + // Temp tabs are bound to a single conversation partner — other tells + // matching the channel filter must not land here. if (IsTempTab && TellTarget?.IsSet() == true) - { return ChunkUtil.MatchesSender(message, TellTarget.Name, TellTarget.World); - } return true; } @@ -610,10 +493,7 @@ public class Tab }; } - /// - /// MessageList provides an ordered list of messages with duplicate ID - /// tracking, sorting and mutex protection. - /// + /// Ordered message list with duplicate ID tracking, sorting and mutex protection. public class MessageList { private readonly SemaphoreSlim LockSlim = new(1, 1); @@ -701,10 +581,7 @@ public class Tab } } - /// - /// Aktuelle Anzahl der gespeicherten Messages. Lock-acquire pro Read - /// ist OK für 1×/sec Status-Bar-Polling (v1.2.0). - /// + /// Current message count. Lock-per-read is acceptable for 1×/sec status bar polling. public int Count { get @@ -721,9 +598,7 @@ public class Tab } } - /// - /// Returns an array copy of the message list for usage outside of main thread - /// + /// Returns an array copy of the message list for usage outside of main thread. public async Task GetCopy(int millisecondsTimeout = -1) { await LockSlim.WaitAsync(millisecondsTimeout); @@ -737,10 +612,7 @@ public class Tab } } - /// - /// GetReadOnly returns a read-only list of messages while holding a - /// reader lock. The list should be used with a using statement. - /// + /// Returns a read-only list while holding a reader lock. Use with a using statement. public RLockedMessageList GetReadOnly(int millisecondsTimeout = -1) { LockSlim.Wait(millisecondsTimeout); diff --git a/HellionChat/EmoteCache.cs b/HellionChat/EmoteCache.cs index e47143d..320006f 100644 --- a/HellionChat/EmoteCache.cs +++ b/HellionChat/EmoteCache.cs @@ -79,7 +79,7 @@ public static class EmoteCache Done, } - // All of this data is uninitalized while State is not `LoadingState.Done` + // All fields below are uninitialised while State != Done. public static LoadingState State = LoadingState.Unloaded; private static readonly Dictionary Cache = new(); @@ -87,15 +87,11 @@ public static class EmoteCache public static string[] SortedCodeArray = []; - // Plugin-scoped cancellation source for in-flight emote loads. Dispose - // cancels every running download/texture-create so the workers don't - // touch a torn-down TextureProvider on plugin reload. Replaced with a - // fresh source on the next LoadData() call so a re-enable still works. + // Cancelled on Dispose to stop in-flight downloads; replaced on re-enable. private static CancellationTokenSource Cts = new(); internal static CancellationToken Token => Cts.Token; - // Drain target for in-flight loads on Dispose; without this an orphan - // continuation could still write to a torn-down Texture/Frames field. + // Tracks in-flight loads so Dispose can drain them before teardown. private static readonly ConcurrentBag PendingLoads = new(); internal static void TrackLoad(Task loadTask, string emoteCode) @@ -117,8 +113,7 @@ public static class EmoteCache if (State is not LoadingState.Unloaded) return; - // Refresh the CTS in case Dispose was called and we're being re-enabled - // in the same process (Dalamud /xlplugins toggle). + // Reset CTS if Dispose was called and the plugin is being re-enabled. if (Cts.IsCancellationRequested) Cts = new CancellationTokenSource(); @@ -140,11 +135,8 @@ public static class EmoteCache var topList = await top.Content.ReadAsStringAsync(ct); var jsonList = JsonSerializer.Deserialize>(topList)!; - // BetterTTV occasionally returns entries with a null Code; the - // upstream code passed those straight into Dictionary.TryAdd - // and tripped ArgumentNullException, killing the whole emote - // load. Skip them defensively so a single bad row no longer - // breaks the cache for everyone else. + // BetterTTV occasionally returns entries with a null Code; + // skip them so a single bad row doesn't break the whole cache. foreach (var emote in jsonList) if ( !string.IsNullOrEmpty(emote.Emote.Code) @@ -160,16 +152,11 @@ public static class EmoteCache } catch (OperationCanceledException) { - // Plugin disposed while the cache was loading; leave State on - // Loading so a subsequent re-enable can re-issue LoadData with - // a fresh CTS (handled above). + // Plugin disposed mid-load; State stays on Loading so re-enable can retry. } catch (Exception ex) { - // Reset to Unloaded so a later trigger (e.g. the user reopening - // the Emotes tab after the network recovers) can retry. Without - // this the State stays on Loading and the early-out at the top - // of LoadData blocks every further attempt until plugin reload. + // Reset to Unloaded so a later trigger can retry without a plugin reload. State = LoadingState.Unloaded; Plugin.Log.Error(ex, "BetterTTV cache wasn't initialized"); } @@ -248,11 +235,8 @@ public static class EmoteCache internal async Task LoadAsync(Emote emote, CancellationToken ct) { - // BetterTTV-supplied Id and ImageType are interpolated straight - // into the filename. HTTPS protects the wire, but a compromised - // upstream could still hand us "../foo" and write into the - // pluginConfigs root (or worse). Resolve the candidate path and - // refuse anything that escapes the cache directory. + // Path-traversal guard: resolve and verify the candidate path stays + // inside the cache directory before reading or writing. var dir = Path.GetFullPath( Path.Join(Plugin.Interface.ConfigDirectory.FullName, "EmoteCacheV1") ); @@ -397,7 +381,7 @@ public static class EmoteCache var delay = frame.Metadata.GetGifMetadata().FrameDelay / 100f; - // Follows the same pattern as browsers, anything under 0.02s delay will be rounded up to 0.1s + // Match browser behaviour: anything under 20ms rounds up to 100ms. if (delay < 0.02f) delay = 0.1f; @@ -416,8 +400,7 @@ public static class EmoteCache } catch (OperationCanceledException) { - // Plugin disposed mid-load; partial frames are released by - // InnerDispose on the next dispose pass. + // Plugin disposed mid-load; release any partial frames. foreach (var f in Frames) f.Texture.Dispose(); Frames = []; diff --git a/HellionChat/FontManager.cs b/HellionChat/FontManager.cs index 80d0535..6a86efa 100644 --- a/HellionChat/FontManager.cs +++ b/HellionChat/FontManager.cs @@ -41,12 +41,7 @@ public class FontManager 90f, ]; - /// - /// Backing bytes for the bundled Hellion font (Exo 2, OFL-1.1). Lazily - /// extracted from the assembly's manifest resources on first use; the - /// load happens inside the font atlas build callback so we keep the - /// allocation off the plugin constructor's hot path. - /// + // Hellion font bytes (Exo 2, OFL-1.1); lazily loaded from manifest resources private static byte[]? HellionFontBytes; private static byte[] GetHellionFontBytes() @@ -70,11 +65,9 @@ public class FontManager ushort[] BuildRange(IReadOnlyList? chars, params nint[] ranges) { var builder = new ImFontGlyphRangesBuilderPtr(ImGuiNative.ImFontGlyphRangesBuilder()); - // text foreach (var range in ranges) builder.AddRanges((ushort*)range); - // chars if (chars != null) { for (var i = 0; i < chars.Count; i += 2) @@ -116,13 +109,7 @@ public class FontManager JpRange = BuildRange(GlyphRangesJapanese.GlyphRanges); } - /// - /// Async wrapper around for the Phase-1 LoadAsync - /// path. The font-atlas build is CPU-bound, so we offload via Task.Run and - /// honour the cancellation token at the scheduling boundary; this lets the - /// font build run in parallel with the theme init without blocking the - /// loader. Settings-driven manual rebuilds keep using the sync entry point. - /// + // CPU-bound build offloaded to Task.Run; runs parallel with theme init public async Task BuildFontsAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -154,12 +141,7 @@ public class FontManager RegularFont = Plugin.Interface.UiBuilder.FontAtlas.NewDelegateFontHandle(e => e.OnPreBuild(tk => { - // v1.2.0 — Bei aktiver Hellion-Schrift (Exo 2 ist Variable-Font) - // wird die User-Schriftgröße aus FontSizeV2 als SizePt angewendet. - // Der Bestand-Pfad nutzt weiter GlobalFontV2.SizePt aus dem - // Custom-Font-Stack. Ohne diese Verzweigung war FontSizeV2 bei - // UseHellionFont=true wirkungslos, was 4K-User mit größerer - // Skalierung blockierte (Settings → Erscheinungsbild → Schriftarten). + // v1.2.0: UseHellionFont controls font size selection var basePt = Plugin.Config.UseHellionFont ? Plugin.Config.FontSizeV2 : Plugin.Config.GlobalFontV2.SizePt; @@ -218,13 +200,7 @@ public class FontManager } } - /// - /// Try to add a user-configured font to the build toolkit, falling back to - /// the bundled NotoSansCjkRegular asset if the configured font isn't - /// available on the system. Without this guard a stale SystemFontId - /// pointing at a font the user uninstalled or that never existed on - /// Linux (e.g. "Crimson Text") tears down the entire font atlas build. - /// + // Add font with fallback to NotoSansCjkRegular if unavailable private static ImFontPtr AddFontWithFallback( IFontAtlasBuildToolkitPreBuild tk, IFontId fontId, diff --git a/HellionChat/HellionChat.csproj b/HellionChat/HellionChat.csproj index 50d8d5d..67d73d1 100644 --- a/HellionChat/HellionChat.csproj +++ b/HellionChat/HellionChat.csproj @@ -1,36 +1,21 @@ - + 1.4.3 enable enable - + true - + HellionChat HellionChat - + - + @@ -38,9 +23,7 @@ - + @@ -59,15 +42,7 @@ - - - + HellionFont.ttf @@ -80,14 +55,7 @@ - + PreserveNewest diff --git a/HellionChat/HellionChat.yaml b/HellionChat/HellionChat.yaml index fe6b000..2e41e34 100755 --- a/HellionChat/HellionChat.yaml +++ b/HellionChat/HellionChat.yaml @@ -31,26 +31,6 @@ description: |- - Independent plugin state — own config file and database directory, so Hellion Chat does not share state with upstream Chat 2 - v1.3.0 First plugin integration cycle. Honorific custom titles - are shown in the chat header above the message log, with auto-detect - and silent fallback when Honorific is not installed. - - v1.4.0 — Critical Lifecycle Fixes. Plugin reload and shutdown - are cleaner: SQLite no longer leans on GC pressure to release - its file, worker threads are explicitly background, deferred - config saves no longer get lost mid-disable, and pre-v13 config - backups carry the user's custom theme opacity into the v14 schema - instead of falling back to the default. - - v1.4.1 — Theme Engine Performance plus a tenth built-in. - HellionStyle.PushGlobal reads pre-computed ABGR values from a - per-theme cache instead of converting RGBA per slot per frame - (~13 % render-time recovery in typical scenes). Custom-theme - hot-reload survives transient file locks (editor mid-save - keeps the last-known-good snapshot). Synthwave Sunset joins - as the tenth built-in theme — Hot Magenta + Cyan on midnight - violet, 80s neon-grid vibes. - v1.4.2 — ChatLog Frame-Hot-Path. Three per-frame allocation patterns gone from the chat-log render path: card-mode borders hoist invariants out of the per-message loop, auto-tell tab @@ -184,38 +164,6 @@ changelog: |- Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2). - **Hellion Chat 1.4.0 — Critical Lifecycle Fixes** - - First sub-patch of the v1.4.x Polish Sweep series. Seven - known lifecycle and race bugs eliminated before any - performance refactor sits on top. - - - MessageStore disposal no longer triggers GC.Collect - globally; Pooling=false on the SQLite connection means - there's nothing left to clean up by hand - - PendingMessage and RetentionSweep worker threads are - explicitly marked IsBackground=true so the plugin domain - can unload during XIVLauncher reload without waiting - for them - - EmoteCache image and gif loaders moved from async-void - to async Task with a shared task tracker, draining - on Dispose so an in-flight load can no longer write - to a disposed EmoteImages entry - - DisposeAsync 10s timeout now warns loudly instead of - silently leaving the worker behind - - Plugin.Dispose flushes any pending DeferredSaveFrames - before tearing services down, so settings changes - made in the last few frames before disable are no - longer lost - - The v13→v14 config migration now reads the pre-v13 - backup and carries HellionThemeWindowOpacity into the - new WindowOpacity field instead of falling back to - the default 0.85 - - Modding & support: join Hellion Forge — https://discord.gg/X9V7Kcv5gR - - Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2). - --- Earlier history: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases diff --git a/HellionChat/InputHistoryService.cs b/HellionChat/InputHistoryService.cs index cc653db..d7d9300 100644 --- a/HellionChat/InputHistoryService.cs +++ b/HellionChat/InputHistoryService.cs @@ -2,14 +2,8 @@ using System.Collections.Generic; namespace HellionChat; -// Hellion Chat — v0.6.0 shared input history. Replaces the embedded -// ChatLogWindow.InputBacklog so that pop-out windows with their own -// ChatInputBar can navigate the same Up/Down history as the main window. -// Index semantics are kept identical to the v0.5.x InputBacklog: -// index 0 = oldest entry -// index Count - 1 = newest entry -// Push performs move-to-newest deduplication: existing entries are -// removed before the new one is appended at the end. +// Shared input history for all ChatInputBars (main and pop-out windows). +// Push deduplicates: existing entries are moved to the end when re-added. public static class InputHistoryService { private const int MaxSize = 30; @@ -26,8 +20,7 @@ public static class InputHistoryService var trimmed = entry.Trim(); - // Move-to-newest: existing entries are removed before the append - // so the same line typed twice does not occupy two history slots. + // Move-to-newest: remove existing entry before adding at the end for (var i = 0; i < _entries.Count; i++) { if (_entries[i] == trimmed) diff --git a/HellionChat/Integrations/HonorificService.cs b/HellionChat/Integrations/HonorificService.cs index a251560..a4467af 100644 --- a/HellionChat/Integrations/HonorificService.cs +++ b/HellionChat/Integrations/HonorificService.cs @@ -6,25 +6,17 @@ using Newtonsoft.Json; namespace HellionChat.Integrations; -// We pull Newtonsoft.Json into this single file for IPC compatibility: -// Honorific serialises its TitleData with Newtonsoft (see -// Honorific-master/IpcProvider.cs:9 and CustomTitle.cs:12). Using the -// same library guarantees identical handling of System.Numerics.Vector3? -// and the enum fields we ignore. Newtonsoft is a transitive dependency -// via Dalamud, so no new NuGet entry is needed. The rest of HellionChat -// keeps using System.Text.Json. +// Newtonsoft.Json is used here for IPC compatibility with Honorific, which +// serialises TitleData with it. It's a transitive Dalamud dependency — no +// new NuGet entry needed. The rest of HellionChat uses System.Text.Json. internal sealed class HonorificService : IDisposable { private const string IpcNamespace = "Honorific"; - // Major version of the Honorific IPC contract HellionChat is built against. - // Used both by the runtime compatibility check and by the settings tab when - // it tells the user which major version we expected, so the literal lives - // in exactly one place. + // Major version of the Honorific IPC contract we're built against. internal const uint ExpectedApiMajor = 3; - // IPC gates we subscribe to. Keep them as fields so Dispose can - // unsubscribe the same instances we subscribed in the constructor. + // IPC gates — kept as fields so Dispose can unsubscribe the same instances. private readonly ICallGateSubscriber<(uint, uint)> _apiVersion; private readonly ICallGateSubscriber _getLocalCharacterTitle; private readonly ICallGateSubscriber _localCharacterTitleChanged; @@ -48,23 +40,11 @@ internal sealed class HonorificService : IDisposable _framework = framework; _log = log; - // Dalamud caches gate objects per-name for the lifetime of the - // plugin interface, so we can register subscribers even when - // Honorific isn't loaded yet — the gate just won't fire. Calling - // InvokeFunc before Honorific is up will throw, which is why the - // initial pull below is wrapped in try-catch. - // - // Thread-context: plugin constructors run on Dalamud's plugin-loader - // thread, NOT the framework thread. Honorific's IPC handlers read - // ObjectTable.LocalPlayer (Honorific IpcProvider.cs:61), which throws - // "Not on main thread!" outside the framework thread. If Honorific is - // already loaded when HellionChat starts, a synchronous InvokeFunc - // here would surface that exception, the broad catch below would - // mark IsAvailable=false, and OnTitleChanged's `if (!IsAvailable)` - // gate would block every subsequent title update. We therefore - // schedule the initial pull onto the framework thread via - // IFramework.RunOnFrameworkThread so the IPC call sees the right - // thread context. + // Gate objects are cached per-name by Dalamud and safe to register + // before Honorific loads — they just won't fire until it does. + // Initial pull is scheduled on the framework thread because plugin + // constructors run on the loader thread, and Honorific's IPC handlers + // read ObjectTable.LocalPlayer which throws off the framework thread. _apiVersion = pluginInterface.GetIpcSubscriber<(uint, uint)>($"{IpcNamespace}.ApiVersion"); _getLocalCharacterTitle = pluginInterface.GetIpcSubscriber( $"{IpcNamespace}.GetLocalCharacterTitle" @@ -84,11 +64,8 @@ internal sealed class HonorificService : IDisposable public void Dispose() { - // Honorific may already be gone by the time we dispose. Wrap each - // unsubscribe so a missing gate doesn't prevent the others from - // unsubscribing — leaking even one subscription leaves a callback - // alive that captures `this`, which keeps the whole service alive - // and breaks plugin reload. + // Wrap each unsubscribe — a missing gate must not block the others. + // Leaking a subscription keeps this service alive across plugin reloads. TryUnsubscribe(() => _localCharacterTitleChanged.Unsubscribe(OnTitleChanged)); TryUnsubscribe(() => _ready.Unsubscribe(OnReady)); TryUnsubscribe(() => _disposing.Unsubscribe(OnDisposing)); @@ -119,34 +96,21 @@ internal sealed class HonorificService : IDisposable IsAvailable = true; _versionWarningLogged = false; - // Pull the current title once at startup; from here on we rely - // on LocalCharacterTitleChanged events. var json = _getLocalCharacterTitle.InvokeFunc(); CurrentTitle = ParseTitleJson(json); } catch (Exception ex) { - // Honorific isn't installed or hasn't initialised yet. The Ready - // event will give us a second chance later. Log at Debug so - // users without Honorific don't see noise on every reload. + // Honorific not installed or not yet initialised — Ready will retry. _log.Debug(ex, "Honorific not available at HellionChat startup; awaiting Ready."); IsAvailable = false; CurrentTitle = null; } } - // Honorific fires LocalCharacterTitleChanged through its nameplate hook - // (Honorific-master/Plugin.cs:665), which means we get title updates on - // character switches automatically as soon as the new character is - // rendered. While the user is in the character-select menu, HellionChat's - // window is hidden by default via HideWhenNotLoggedIn (Configuration.cs:152), - // so the stale-title window between logout and login isn't user-visible. private void OnTitleChanged(string json) { - // Don't update cached state when we've already decided we can't trust - // Honorific (e.g. version mismatch). Subscription stays live in case a - // compatible Honorific reloads, in which case Ready triggers TryInitialPull - // and sets IsAvailable back to true. + // Skip updates on version mismatch; subscription stays live for reload. if (!IsAvailable) return; CurrentTitle = ParseTitleJson(json); @@ -154,28 +118,16 @@ internal sealed class HonorificService : IDisposable private void OnReady() { - // Honorific loaded after HellionChat; redo the version check and - // initial pull. Idempotent on purpose — Honorific can fire Ready - // more than once across reloads. - // - // Honorific's NotifyReady may dispatch from any thread, and - // TryInitialPull eventually calls IPC handlers that read - // ObjectTable.LocalPlayer — same "Not on main thread!" hazard as - // the constructor path. Schedule onto the framework thread. + // Schedule on framework thread — NotifyReady can dispatch from any thread. _framework.RunOnFrameworkThread(TryInitialPull); } private void OnDisposing() { - // Honorific is unloading. Drop our cached state so the header - // hides on the next frame; subscriptions stay registered because - // the gates may come back later (Honorific reload). - // - // Race-note: Honorific's NotifyDisposing calls ChangedLocalCharacterTitle(null) - // BEFORE SendMessage on the Disposing gate (IpcProvider.cs:109-111), - // so OnTitleChanged is expected to fire first and already null out - // CurrentTitle. We re-clear here as belt-and-braces; should the - // ordering ever flip, ShouldRenderSlot would still gate on IsAvailable. + // Honorific unloading — clear cached state so the header hides next frame. + // Subscriptions stay registered in case Honorific reloads. + // CurrentTitle is already nulled by OnTitleChanged before this fires, + // re-clearing here is belt-and-braces. CurrentTitle = null; IsAvailable = false; DetectedApiVersion = null; @@ -193,28 +145,15 @@ internal sealed class HonorificService : IDisposable } } - // Threading note: Dalamud fires IPC events on the framework thread and - // ImGui renders on the framework thread, so OnTitleChanged and the - // render path that reads CurrentTitle never race — OnTitleChanged is - // safe to keep direct (no RunOnFrameworkThread wrap needed) because - // LocalCharacterTitleChanged delivery is framework-thread by Dalamud - // contract. If a future change moves either side onto a worker thread, - // switch to volatile/Interlocked for the CurrentTitle field. + // Threading: IPC events and ImGui both run on the framework thread, so + // OnTitleChanged and the render path never race — no volatile/Interlocked + // needed as long as Dalamud's framework-thread delivery contract holds. // - // The constructor's initial pull and OnReady, on the other hand, are - // explicitly scheduled via IFramework.RunOnFrameworkThread because - // they run outside that contract: the constructor executes on the - // plugin-loader thread, and Honorific's NotifyReady can dispatch from - // any thread. Both call paths eventually invoke IPC handlers that read - // ObjectTable.LocalPlayer, which throws "Not on main thread!" off the - // framework thread — see the constructor comment block for context. - // - // Divergence from ChatTwo/Ipc/ExtraChat.cs: that file uses `volatile` - // on its state fields out of caution. We don't, because the framework- - // thread delivery is the documented Dalamud contract. If the two files - // ever need to share a threading audit, this is the place to revisit. + // Constructor and OnReady are exceptions: they run outside that contract + // (plugin-loader thread and Honorific's NotifyReady respectively), so both + // use RunOnFrameworkThread to safely reach ObjectTable.LocalPlayer. - // --- Pure-logic helpers below; tested via HellionChat.Tests/Integrations. --- + // --- Pure-logic helpers; tested via HellionChat.Tests/Integrations. --- internal static HonorificTitleData? ParseTitleJson(string json) { diff --git a/HellionChat/Integrations/HonorificTitleData.cs b/HellionChat/Integrations/HonorificTitleData.cs index 3f67e32..5363851 100644 --- a/HellionChat/Integrations/HonorificTitleData.cs +++ b/HellionChat/Integrations/HonorificTitleData.cs @@ -2,13 +2,9 @@ using System.Numerics; namespace HellionChat.Integrations; -// Local DTO mirroring Honorific's TitleData shape. We replicate the structure -// instead of referencing Honorific.dll because a hard build-time dependency -// would couple the two assemblies and break HellionChat at load time when -// Honorific is missing. Glow, Color3, GradientColourSet and GradientAnimationStyle -// are intentionally omitted — Cycle 1 renders text in the primary Color only; -// the "Honorific Full Fidelity" backlog item adds them later as a pure -// extension that won't break this DTO's existing consumers. +// Local DTO mirroring Honorific's TitleData — no hard reference to Honorific.dll +// so HellionChat loads cleanly when Honorific is absent. +// Glow/gradient fields omitted; Cycle 1 renders primary Color only. internal sealed record HonorificTitleData( string? Title, bool IsPrefix, diff --git a/HellionChat/Integrations/IntegrationLinks.cs b/HellionChat/Integrations/IntegrationLinks.cs index fd21bc2..84253a7 100644 --- a/HellionChat/Integrations/IntegrationLinks.cs +++ b/HellionChat/Integrations/IntegrationLinks.cs @@ -1,10 +1,6 @@ namespace HellionChat.Integrations; -// External URLs for the third-party plugins HellionChat integrates with. -// Kept separate from BrandingLinks (which is for Hellion-owned URLs) so -// future cycles can extend this file with maintainer attribution links -// for Moodles, NotificationMaster, ExtraChat, etc. without polluting the -// brand-links class. +// Third-party plugin URLs — separate from BrandingLinks (Hellion-owned URLs). internal static class IntegrationLinks { public const string HonorificRepo = "https://github.com/Caraxi/Honorific"; diff --git a/HellionChat/MessageManager.cs b/HellionChat/MessageManager.cs index f64f13d..7d74ede 100644 --- a/HellionChat/MessageManager.cs +++ b/HellionChat/MessageManager.cs @@ -27,16 +27,7 @@ internal class MessageManager : IAsyncDisposable private Dictionary Formats { get; } = []; private ulong LastContentId { get; set; } - // Messages go into the PendingSync queue first, which will be consumed one - // at a time in the main thread. This is to delay the async processing until - // after we've received the content ID from the ContentIdResolver hook. - // - // After that, the message is enqueued in the PendingAsync queue, which will - // be consumed in a separate thread and perform more processing (emotes, - // URLs) as well as inserting the message into the database. - // LinkedList instead of Queue: ContentIdResolver hits PendingSync.Last - // every hook call. Queue.Last() is the LINQ extension and walks the - // whole queue (O(n)); LinkedList.Last is an O(1) node reference. + // PendingSync (main thread) → PendingAsync (worker thread); LinkedList for O(1) Last access private LinkedList PendingSync { get; } = []; private ConcurrentQueue PendingAsync { get; } = []; private readonly Thread PendingMessageThread; @@ -53,11 +44,8 @@ internal class MessageManager : IAsyncDisposable } } - // Hellion Chat — Auto-Tell-Tabs hook. Fires after a fully processed - // message has been routed to all matching persistent tabs and stored - // in the database. The AutoTellTabsService subscribes to spawn or - // refresh temp tabs without having to wedge itself into ProcessMessage - // directly. + // Auto-Tell-Tabs hook: fires after a message is processed and stored, allowing + // AutoTellTabsService to spawn or refresh temp tabs without coupling. public event Action? MessageProcessed; internal unsafe MessageManager(Plugin plugin) @@ -66,8 +54,6 @@ internal class MessageManager : IAsyncDisposable Store = new MessageStore(DatabasePath()); - // IsBackground so a stuck worker never blocks plugin unload. - // Cooperative cancel via PendingThreadCancellationToken first, background flag is the safety net. PendingMessageThread = new Thread(() => ProcessPendingMessages(PendingThreadCancellationToken.Token) ) @@ -107,12 +93,9 @@ internal class MessageManager : IAsyncDisposable if (PendingMessageThread.IsAlive) Plugin.Log.Warning( "PendingMessageThread did not observe cancellation within 10s. " - + "Worker remains on a background thread; next plugin reload releases it. " - + "If this recurs, file a bug with /xllog after the previous reload." + + "Worker remains on background thread; next plugin reload releases it." ); - // CTS owns an unmanaged WaitHandle; dispose even if the worker is - // alive — it checks IsCancellationRequested via the linked token. PendingThreadCancellationToken.Dispose(); Store.Dispose(); @@ -166,12 +149,7 @@ internal class MessageManager : IAsyncDisposable internal void ClearAllTabs() { - // Hellion Chat — TempTabs haben keine DB-Persistenz (session-only, - // direkt vom AutoTellTabsService befüllt). Ein Clear+Refilter würde - // sie leer hinterlassen weil FilterAllTabs nichts aus der DB - // findet — Tells sind oft durch Privacy-Filter blockiert oder - // schlicht session-flüchtig. TempTabs vom Clear-Pfad ausschließen - // damit Settings-Save den Tell-Verlauf nicht zerstört. + // TempTabs are session-only (not persisted); exclude them to preserve Tell history foreach (var tab in Plugin.Config.Tabs.Where(t => !t.IsTempTab)) tab.Clear(); } @@ -184,12 +162,7 @@ internal class MessageManager : IAsyncDisposable using var messages = Store.GetMostRecentMessages(CurrentContentId, since); - // We store the pending messages to be added to the chat log in a - // temporary list, and apply them all at once after filtering. - // TempTabs werden ausgeschlossen — sie bleiben live-state aus dem - // AutoTellTabsService, ein DB-Refilter würde sie nur partial - // wiederherstellen falls Tells in DB liegen, oder leer lassen wenn - // Privacy-Filter sie blockiert hat. + // TempTabs are excluded; they maintain live state from AutoTellTabsService var pendingTabs = Plugin .Config.Tabs.Where(t => !t.IsTempTab) .Select(tab => (tab, new List())) @@ -198,7 +171,7 @@ internal class MessageManager : IAsyncDisposable foreach (var (_, pendingMessages) in pendingTabs.Where(ptab => ptab.Item1.Matches(message))) pendingMessages.Add(message); - // Apply the messages to the chat log in one go. + // Apply messages to chat log all at once. foreach (var (tab, pendingMessages) in pendingTabs) tab.Messages.AddSortPrune(pendingMessages, MessageDisplayLimit); @@ -207,8 +180,7 @@ internal class MessageManager : IAsyncDisposable WrapperUtil.AddNotification(Language.LoadMessages_Error, NotificationType.Error); - // Mark the failed messages as deleted so we don't try to load them - // again. + // Mark failed messages as deleted to prevent retry attempts var failedIds = messages.FailedMessageIds(); Plugin.Log.Info($"Marking {failedIds.Count} messages as deleted due to parse failures"); foreach (var msgId in messages.FailedMessageIds()) @@ -256,16 +228,10 @@ internal class MessageManager : IAsyncDisposable // Update colour codes. GlobalParametersCache.Refresh(); - // We delay messages to be handed off to the async processing thread - // in the next tick, otherwise we can't get the content ID from the hook - // below. + // Delay to next tick to get content ID from ContentIdResolver hook PendingSync.AddLast(pendingMessage); } - // This hook is called immediately after receiving a message with the - // message's content ID. If multiple messages are received in the same tick, - // this will be called for each message immediately after ChatMessage is - // called for each message. private unsafe void ContentIdResolver( RaptureLogModule* agent, ulong contentId, @@ -408,7 +374,7 @@ internal class MessageManager : IAsyncDisposable var after = formats .GetRange(firstStringParam + 1, secondStringParam - firstStringParam) .Where(payload => payload.Type == ReadOnlySePayloadType.Text) - .Select(text => Encoding.UTF8.GetString(text.Body.Span)); // Can't use `ToString()` as it defaults to macro + .Select(text => Encoding.UTF8.GetString(text.Body.Span)); var nameFormatting = NameFormatting.Of(string.Join("", before), string.Join("", after)); Formats[type] = nameFormatting; diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 19d9cf2..a8411f5 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -90,10 +90,8 @@ public sealed class Plugin : IAsyncDalamudPlugin public readonly WindowSystem WindowSystem = new(PluginName); - // v1.4.3: properties moved from { get; } to { get; private set; } = null!; - // because LoadAsync now owns construction of the Phase-2 services. - // Phase-1 services use the same shape for consistency, even though - // they're still allocated in the ctor. + // Phase-2 services are constructed in LoadAsync; null! shape is kept + // consistent across all properties for clarity. public SettingsWindow SettingsWindow { get; private set; } = null!; public ChatLogWindow ChatLogWindow { get; private set; } = null!; public DbViewer DbViewer { get; private set; } = null!; @@ -115,27 +113,20 @@ public sealed class Plugin : IAsyncDalamudPlugin internal Ui.StatusBar StatusBar { get; private set; } = null!; internal Integrations.HonorificService HonorificService { get; private set; } = null!; - // (B3) Lightless idempotency guard — Dalamud may fire DisposeAsync twice - // in a reload race; second call short-circuits. + // Idempotency guard — Dalamud may fire DisposeAsync twice in a reload race. private int _disposeStarted; internal int DeferredSaveFrames = -1; - // Serialises retention sweeps. The 24h auto-sweep on plugin load and - // the manual button in the Privacy tab both run on background threads; - // without this gate, hitting the manual button moments after a fresh - // plugin start would launch two sweeps in parallel and the second one - // would just re-do work the first one already finished. The lock guards - // the flag — the flag check itself bails before we touch the database. - // Volatile because the ImGui thread reads the flag outside the lock to - // gate the manual button; without it the JIT may cache the value in a - // register and miss the background-thread update. + // Serialises retention sweeps so a manual trigger and the 24h auto-sweep + // can't run in parallel. Volatile because the ImGui thread reads it outside + // the lock to gate the manual button. internal readonly object RetentionSweepLock = new(); internal volatile bool RetentionSweepRunning; internal DateTime GameStarted { get; } - // Tab management needs to happen outside the chatlog window class for access reasons + // Tab management lives here rather than in ChatLogWindow for access reasons. internal int LastTab { get; set; } internal int? WantedTab { get; set; } internal Tab CurrentTab @@ -149,31 +140,22 @@ public sealed class Plugin : IAsyncDalamudPlugin public Plugin() { - // Phase-1 ctor stays minimal: bootstrap-essentials only (conflict - // gate, config load, language + ImGui init, WindowSystem skeleton). - // Schema migrations and every service / window allocation moved to - // LoadAsync so the sync ctor returns fast. On failure here nothing - // is initialized yet, so just throw — there is nothing to clean up. + // Phase-1 ctor: bootstrap-essentials only (conflict gate, config load, + // language + ImGui init). All service/window allocation lives in LoadAsync. - // Refuse to start if upstream Chat 2 is loaded — prevents IPC - // channel collisions and double-replacement of the in-game chat - // window. Throwing here makes Dalamud abort the load cleanly with - // our localized message instead of crashing FFXIV mid-frame. + // Block load if upstream Chat 2 is active — prevents IPC collisions + // and double-replacement of the in-game chat window. ChatTwoConflictDetector.ThrowIfChatTwoIsLoaded(Interface); GameStarted = Process.GetCurrentProcess().StartTime.ToUniversalTime(); - // Hellion Chat: take over config + database from upstream ChatTwo - // before Dalamud loads our plugin config. Idempotent: only acts on - // the first start where the legacy paths exist and ours don't. + // Migrate config + database from upstream ChatTwo on first start. MigrateFromChatTwoLayout(); Config = Interface.GetPluginConfig() as Configuration ?? new Configuration(); - // Schema-gate: v1.4.3 only supports config schema v16. Older configs - // went through their migrations in v1.2.1 (v15→v16) and earlier; users - // who skipped past those releases need to install v1.4.2 first to run - // the migration chain, then upgrade to v1.4.3. + // Schema gate: v1.4.3 requires config v16. Users on older schemas + // must install v1.4.2 first to run the migration chain. if (Config.Version < 16) { throw new InvalidOperationException( @@ -182,19 +164,13 @@ public sealed class Plugin : IAsyncDalamudPlugin ); } - // Hellion Chat — Auto-Tell-Tabs Defense-in-Depth. SaveConfig - // already strips temp tabs before persistence, but a previous - // crash or external write could have left them in the JSON. - // Drop them on load to guarantee the session-only invariant. + // Drop session-only Auto-Tell-Tabs that a previous crash may have persisted. Config.Tabs.RemoveAll(t => t.IsTempTab); LanguageChanged(Interface.UiLanguage); ImGuiUtil.Initialize(this); DeferredSaveFrames = -1; - - // WindowSystem skeleton is initialised by the readonly field above — - // no AddWindow yet; window construction lives in LoadAsync. } public async Task LoadAsync(CancellationToken cancellationToken) @@ -203,14 +179,8 @@ public sealed class Plugin : IAsyncDalamudPlugin try { - // Hellion v1.0.0 default tab layout. Five thematically separated - // tabs: General catches the immediate-surroundings public chat - // (Say/Yell/Shout) only; System absorbs the rest of the technical - // and gameplay-event noise; FreeCompany, Group and Linkshell each - // own their respective channel set. Tells are not in a static - // tab anymore — Auto-Tell-Tabs spawns dedicated per-conversation - // tabs on demand. Novice-Network gets no preset tab; users who - // want it can add HellionBeginner from Settings → Tabs. + // Default tab layout on fresh install. Tells are handled by + // Auto-Tell-Tabs; Novice Network has no preset tab by design. if (Config.Tabs.Count == 0) { Config.Tabs.Add(TabsUtil.VanillaGeneral); @@ -222,19 +192,12 @@ public sealed class Plugin : IAsyncDalamudPlugin cancellationToken.ThrowIfCancellationRequested(); - // Sync allocation + handle registration. BuildFonts() registers - // IFontHandles with Dalamud's UiBuilder.FontAtlas — registration - // itself is non-blocking (handles stored, lambdas queued). Dalamud - // rebuilds the atlas on its own pipeline a few frames later; first - // frames render with the default font until the rebuild lands and - // ImGui switches to Hellion-Exo2 / NotoSans (visible "font-pop"). - // Mirrors ChatTwo Plugin.cs:152. + // BuildFonts registers handles with Dalamud's FontAtlas; the atlas + // rebuilds async a few frames later (visible "font-pop" on first load). FontManager = new FontManager(); FontManager.BuildFonts(); - // Theme init stays sync on the LoadAsync continuation — cheap, - // and Active is read every Draw frame, so the registry must be - // wired before the first hook fires. + // ThemeRegistry must be wired before the first Draw tick. var customThemesDir = Path.Combine(Interface.ConfigDirectory.FullName, "themes"); Directory.CreateDirectory(customThemesDir); SeedExampleThemeIfEmpty(customThemesDir); @@ -243,11 +206,9 @@ public sealed class Plugin : IAsyncDalamudPlugin cancellationToken.ThrowIfCancellationRequested(); - // Service allocations: order encodes dependencies. Commands is - // alloc-only here; Initialise() runs after windows exist so the - // slash-commands can toggle their visibility. HonorificService - // registers IPC subscribers up-front so Ready/Disposing events - // are caught from the very first frame. + // Service allocations — order encodes dependencies. + // HonorificService registers IPC subscribers early to catch + // Ready/Disposing events from the first frame. FileDialogManager = new FileDialogManager(); Commands = new Commands(); Functions = new GameFunctions.GameFunctions(this); @@ -258,9 +219,6 @@ public sealed class Plugin : IAsyncDalamudPlugin StatusBar = new Ui.StatusBar(); MessageManager = new MessageManager(this); - // Auto-Tell-Tabs subscribes to MessageManager.MessageProcessed for - // live tells and to ClientState.Logout for cleanup; needs the live - // store handed in at construction. AutoTellTabsService = new AutoTellTabsService( this, MessageManager, @@ -268,7 +226,6 @@ public sealed class Plugin : IAsyncDalamudPlugin ); AutoTellTabsService.Initialize(); - // SelfTest steps poll Active per frame and need the registry wired. SelfTestRegistry.RegisterTestSteps([new SelfTests.ThemeSwitchSelfTestStep(this)]); ChatLogWindow = new ChatLogWindow(this); @@ -289,22 +246,19 @@ public sealed class Plugin : IAsyncDalamudPlugin WindowSystem.AddWindow(DebuggerWindow); WindowSystem.AddWindow(FirstRunWizard); - // Open the wizard on a fresh install. Existing ChatTwo users have - // FirstRunCompleted set to true by the v6→v7 migration above. if (!Config.FirstRunCompleted) FirstRunWizard.IsOpen = true; cancellationToken.ThrowIfCancellationRequested(); - // let all the other components register, then initialize commands Commands.Initialise(); - // Daily retention sweep, fire-and-forget. Skips itself when - // disabled or when it already ran within the past 24 hours. + // Daily retention sweep — fire-and-forget, skips when disabled + // or already ran within the past 24 hours. RunRetentionSweepIfDue(); if (Config.ShowEmotes) - _ = EmoteCache.LoadData(); // Fire-and-forget, exceptions caught inside + _ = EmoteCache.LoadData(); if (Interface.Reason is not PluginLoadReason.Boot) MessageManager.FilterAllTabsAsync(); @@ -313,33 +267,22 @@ public sealed class Plugin : IAsyncDalamudPlugin Interface.UiBuilder.DisableGposeUiHide = true; #if !DEBUG - // Fire-and-forget on a worker thread. The first auto-translate use of - // a session may have a sub-second hitch if the cache hasn't filled yet, - // but that's preferable to making every user wait ~300 ms during - // plugin load for a cache they may never touch. ChatTwo (upstream) - // does this sync; we trade load-time for first-use latency. + // Fire-and-forget — first auto-translate use may have a sub-second + // hitch if the cache hasn't filled yet, but avoids blocking load. _ = Task.Run(AutoTranslate.PreloadCache, cancellationToken); #endif cancellationToken.ThrowIfCancellationRequested(); - // (B1) Hooks last: every service and window must be live before - // Dalamud fires our first Draw / FrameworkUpdate tick. Anything - // earlier risks rendering against null FontManager / ThemeRegistry. + // Hooks last — all services and windows must be live before + // the first Draw / FrameworkUpdate tick fires. Framework.Update += FrameworkUpdate; Interface.UiBuilder.Draw += Draw; Interface.LanguageChanged += LanguageChanged; - // Hellion Chat — surface a "main UI" entry point so Dalamud's - // plugin list shows the Open-Plugin button. Settings is the - // most useful landing place; OpenConfigUi is already wired to - // the same toggle inside SettingsWindow. Interface.UiBuilder.OpenMainUi += OpenMainUi; } catch { - // Mirror the v1.4.0 load-failure recovery: hand off to DisposeAsync - // so partially-built services are torn down. Swallow the cleanup - // exception so the original load failure stays the visible cause. try { await DisposeAsync().ConfigureAwait(false); @@ -351,28 +294,22 @@ public sealed class Plugin : IAsyncDalamudPlugin } } - // Suppressing this warning because DisposeAsync may run after a partial - // LoadAsync, so some properties may not be initialized. [SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract")] public async ValueTask DisposeAsync() { - // (B3) Idempotency guard — Dalamud may reload-race us; second - // call short-circuits so we don't double-dispose services. + // Idempotency guard — second call short-circuits on reload race. if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) return; Exception? failure = null; - // Hooks unsubscribe FIRST so no Draw / FrameworkUpdate / LanguageChanged - // tick can fire while we're tearing services down. Mirrors the - // hooks-last subscribe order in LoadAsync. + // Unsubscribe hooks first — mirrors the hooks-last subscribe order in LoadAsync. failure = CaptureFailure(failure, () => Interface.UiBuilder.OpenMainUi -= OpenMainUi); failure = CaptureFailure(failure, () => Interface.LanguageChanged -= LanguageChanged); failure = CaptureFailure(failure, () => Interface.UiBuilder.Draw -= Draw); failure = CaptureFailure(failure, () => Framework.Update -= FrameworkUpdate); - // v1.4.0 F5.3 — flush a pending DeferredSave before service teardown, - // since FrameworkUpdate just got unsubscribed and won't fire it. + // Flush a pending DeferredSave — FrameworkUpdate won't fire it anymore. failure = CaptureFailure( failure, () => @@ -385,13 +322,10 @@ public sealed class Plugin : IAsyncDalamudPlugin } ); - // Auto-Tell-Tabs unsubscribes from MessageProcessed before MessageManager - // goes away. Pure-memory cleanup, no framework-thread requirement. + // Unsubscribe AutoTellTabs before MessageManager goes away. failure = CaptureFailure(failure, () => AutoTellTabsService?.Dispose()); - // v1.4.0 F6.2 — MessageManager has its own async dispose path - // (DB flush, pending-message thread shutdown). Run it before the - // framework-block so the worker threads are quiesced first. + // MessageManager has its own async dispose path (DB flush, thread shutdown). if (MessageManager is not null) { failure = await CaptureFailureAsync( @@ -401,36 +335,24 @@ public sealed class Plugin : IAsyncDalamudPlugin .ConfigureAwait(false); } - // (B4) Game-Function / IPC / UI-Window cleanup MUST run on the - // framework thread. WindowSystem mutations and IPC subscriber - // disposes touch Dalamud state that's only safe from the framework. - // Worker-thread DisposeAsync would race the next Draw tick. - // Per-line CaptureFailure so a single throw can't strand the lines - // behind it; mirrors Lightless DisposeFrameworkBoundServicesAsync. + // Game-function / IPC / window cleanup must run on the framework thread. try { await Framework .RunOnFrameworkThread(() => { - // Game-Functions first — other services may still query - // chat-interactable state during their Dispose. failure = CaptureFailure( failure, () => GameFunctions.GameFunctions.SetChatInteractable(true) ); - // IPC subscribers — dispose before windows so any final - // event firing from the IPC source can't reach a half-torn - // ChatLogWindow. + // IPC subscribers before windows — prevents a final IPC event + // from reaching a half-torn ChatLogWindow. failure = CaptureFailure(failure, () => HonorificService?.Dispose()); failure = CaptureFailure(failure, () => TypingIpc?.Dispose()); failure = CaptureFailure(failure, () => ExtraChat?.Dispose()); failure = CaptureFailure(failure, () => Ipc?.Dispose()); - // Windows — RemoveAllWindows first, then per-window Dispose. - // Order matches the pre-v1.4.3 Dispose body byte-for-byte. - // CommandHelpWindow and FirstRunWizard don't implement - // IDisposable; their resources are reclaimed via WindowSystem. failure = CaptureFailure(failure, () => WindowSystem?.RemoveAllWindows()); failure = CaptureFailure(failure, () => ChatLogWindow?.Dispose()); failure = CaptureFailure(failure, () => DbViewer?.Dispose()); @@ -446,8 +368,7 @@ public sealed class Plugin : IAsyncDalamudPlugin failure ??= ex; } - // Pure-memory cleanups — no Framework / UI / IPC touch, so they - // run on whatever thread DisposeAsync resumes on. + // Pure-memory cleanups — no Framework / UI / IPC touch. failure = CaptureFailure(failure, () => Functions?.Dispose()); failure = CaptureFailure(failure, () => Commands?.Dispose()); failure = CaptureFailure(failure, () => EmoteCache.Dispose()); @@ -456,9 +377,8 @@ public sealed class Plugin : IAsyncDalamudPlugin ExceptionDispatchInfo.Capture(failure).Throw(); } - // Lightless-pattern capture helpers: run cleanup, remember the FIRST - // exception, keep going. Without these one mid-teardown failure would - // skip every cleanup behind it and leave services half-torn. + // Run cleanup actions individually so a single failure doesn't strand + // the remaining teardown steps. private static Exception? CaptureFailure(Exception? failure, Action action) { try @@ -499,9 +419,6 @@ public sealed class Plugin : IAsyncDalamudPlugin var ourConfigFile = Path.Combine(pluginConfigsDir, "HellionChat.json"); var ourConfigDir = Interface.ConfigDirectory.FullName; - // Track whether anything legitimately blocked us. The most common - // cause is upstream Chat 2 still being loaded — its SQLite handle - // keeps chat-sqlite.db locked and File.Move throws IOException. var lockedBlocker = false; try @@ -523,13 +440,6 @@ public sealed class Plugin : IAsyncDalamudPlugin lockedBlocker = true; } - // The plugin's ConfigDirectory may already exist on first load - // (Dalamud creates it), so check at the file level instead of - // skipping when the directory is present. Move every legacy - // entry whose target name is not occupied yet, then remove the - // source dir if it ends up empty. Each move is wrapped on its - // own so a single locked file (the SQLite db while ChatTwo still - // runs) does not abandon the rest of the migration. if (!Directory.Exists(legacyConfigDir)) return; @@ -537,6 +447,8 @@ public sealed class Plugin : IAsyncDalamudPlugin { Directory.CreateDirectory(ourConfigDir); + // Move each file individually so a single locked file (e.g. the + // SQLite db while ChatTwo is still loaded) doesn't abort the rest. foreach (var file in Directory.EnumerateFiles(legacyConfigDir)) { var target = Path.Combine(ourConfigDir, Path.GetFileName(file)); @@ -590,9 +502,6 @@ public sealed class Plugin : IAsyncDalamudPlugin if (lockedBlocker) { - // Surface the most common cause to the user as a notification - // so they don't think Hellion Chat lost their history when in - // fact upstream Chat 2 was still holding the database file. Notification.AddNotification( new Dalamud.Interface.ImGuiNotification.Notification { @@ -610,10 +519,6 @@ public sealed class Plugin : IAsyncDalamudPlugin private void OpenMainUi() { - // Settings is the most useful landing surface — same target as the - // Configure button. SettingsWindow.Toggle is internal and already - // wired to OpenConfigUi, so re-using IsOpen keeps both entry points - // behaviourally identical. SettingsWindow.IsOpen = !SettingsWindow.IsOpen; } @@ -624,8 +529,7 @@ public sealed class Plugin : IAsyncDalamudPlugin if (DateTimeOffset.UtcNow - Config.RetentionLastRunAt < TimeSpan.FromHours(24)) return; - // Snapshot the policy so the user can edit settings while we run. - // Spec defaults form the baseline; explicit user overrides win. + // Snapshot the policy so the user can edit settings while the sweep runs. var policy = new Dictionary(); foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays) policy[(int)(ushort)type] = days; @@ -633,16 +537,10 @@ public sealed class Plugin : IAsyncDalamudPlugin policy[(int)(ushort)type] = days; var defaultDays = Config.RetentionDefaultDays; - // IsBackground = true for the same reason as PendingMessageThread: - // a stuck sweep must never block plugin unload. RunRetentionSweepIfDue - // guards the run-frequency, and the sweep itself uses the framework's - // cooperative cancellation pattern. The background flag is the safety - // net if the sweep ever takes longer than expected. + // IsBackground = true so a stuck sweep never blocks plugin unload. new Thread(() => { - // Bail out cheaply if a manual sweep is already in flight; the - // lock around the actual work would queue us up otherwise and - // we would just re-do whatever the manual run already did. + // Bail early if a manual sweep is already in flight. lock (RetentionSweepLock) { if (RetentionSweepRunning) @@ -659,11 +557,8 @@ public sealed class Plugin : IAsyncDalamudPlugin if (deleted > 0) { Log.Information($"Retention sweep deleted {deleted} expired messages."); - // Run the clear+refilter synchronously on the framework thread. - // Earlier this called FilterAllTabsAsync(), which is fire-and-forget - // — the .Wait() here would return as soon as the inner Task.Run was - // dispatched, racing the next sweep cycle against the still-running - // filter pass. See AUDIT-2026-05-05 [QUAL-02]. + // Run clear+refilter on the framework thread — FilterAllTabsAsync + // is fire-and-forget and would race the next sweep cycle. Framework .Run(() => { @@ -694,9 +589,7 @@ public sealed class Plugin : IAsyncDalamudPlugin private void Draw() { - // Theme-Engine ist ab v14 immer aktiv; Klassik ist jetzt ein eigenes - // Theme statt einem deaktivierten Hellion-Theme. Active wird einmal - // pro Frame aus der Registry gelesen. + // Theme engine is always active; Classic is a theme, not a disabled state. using IDisposable _style = HellionStyle.PushGlobal( ThemeRegistry.Active, Config.WindowOpacity @@ -711,9 +604,7 @@ public sealed class Plugin : IAsyncDalamudPlugin return; } - // v1.0.2 — global skip while the New Game+ menu (QuestRedo addon) is - // open. Hides every plugin window in one shot (chat log, pop-outs, - // settings, db viewer, etc.), matching the LoadingScreens pattern. + // Hide all plugin windows while the New Game+ menu is open. if ( Config.HideInNewGamePlusMenu && GameFunctions.GameFunctions.IsAddonInteractable( @@ -742,10 +633,7 @@ public sealed class Plugin : IAsyncDalamudPlugin internal void SaveConfig() { - // Hellion Chat — Auto-Tell-Tabs are session-only. Strip them out - // before serialization so a crash mid-session can never persist - // them. We snapshot the full tab list first and restore it after - // the save, preserving the user's order and open conversations. + // Strip session-only Auto-Tell-Tabs before serialization; restore after. var snapshot = Config.Tabs.ToList(); Config.Tabs.RemoveAll(t => t.IsTempTab); @@ -794,9 +682,8 @@ public sealed class Plugin : IAsyncDalamudPlugin Condition[ConditionFlag.OccupiedInCutSceneEvent] || Condition[ConditionFlag.WatchingCutscene78]; - // v1.1.0 — wenn der themes/-Ordner leer ist, schreiben wir die embedded - // example-theme.json als Vorlage rein. Bestehende User-Customs werden - // nicht angefasst (existing JSONs lassen den Block überspringen). + // Seeds example-theme.json into the themes dir on first run. + // Skipped if any custom JSON already exists. private static void SeedExampleThemeIfEmpty(string dir) { if (Directory.EnumerateFiles(dir, "*.json").Any()) diff --git a/HellionChat/Resources/ChatColourPresets.cs b/HellionChat/Resources/ChatColourPresets.cs index 9ea4881..4008bdf 100644 --- a/HellionChat/Resources/ChatColourPresets.cs +++ b/HellionChat/Resources/ChatColourPresets.cs @@ -4,11 +4,8 @@ using HellionChat.Util; namespace HellionChat.Resources; -// Hellion Chat — v0.6.0 built-in colour presets for the ChatColours -// settings section. Read-only static data; users apply a preset via the -// settings UI which overwrites Configuration.ChatColours immediately. -// Battle-channel types are intentionally NOT covered by the stylistic -// presets so that combat-log tuning the user has done stays intact. +// Built-in colour presets applied via Settings UI → ChatColours. +// Battle-channel types are intentionally excluded to preserve combat-log tuning. public sealed record ChatColourPreset( string DisplayName, string LocalizationKey, @@ -69,9 +66,7 @@ public static class ChatColourPresets }; } - // The Default preset spiegelt 1:1 die Werte aus ChatTypeExt.DefaultColor. - // Channels ohne Default-Wert (return null) werden ausgelassen — wer sie - // anwenden will, behält seine aktuelle Farbe. + // Mirrors ChatTypeExt.DefaultColor; channels without a default are skipped. private static IReadOnlyDictionary BuildDefault() { var dict = new Dictionary(); @@ -183,33 +178,22 @@ public static class ChatColourPresets }; } - // Hellion brand preset — Arctic Cyan + Ember Orange palette aus - // /mnt/ssd-fast/Projekte/hellion-media/hellion-media-website/BRANDING.md - // (Schema-Stand 2026-04-16). Channels sind über das ganze Brand-Spektrum - // verteilt damit jede Zeile auf einen Glance unterscheidbar ist: - // Cyan-Familie für Standard/Tell, Ember + Warning für laute Channels, - // Status-Farben (Success, Danger) für Linkshells. CrossLinkshells - // nutzen die dunkleren/sattersten Varianten derselben Hue-Familien. + // Hellion brand preset — Arctic Cyan + Ember Orange palette. + // Cyan family for Standard/Tell, Ember/Warning for loud channels, + // Status colours for Linkshells, darker variants for CrossLinkshells. private static IReadOnlyDictionary BuildHellion() { return new Dictionary { - // Standard / Tell — Cyan-Familie (Brand-Primary) [ChatType.Say] = ColourUtil.ComponentsToRgba(77, 217, 232), // Cyan-light #4DD9E8 [ChatType.TellIncoming] = ColourUtil.ComponentsToRgba(0, 190, 210), // Brand Cyan #00BED2 [ChatType.TellOutgoing] = ColourUtil.ComponentsToRgba(0, 151, 167), // Cyan-dark #0097A7 - - // Laute Channels — Ember/Warning [ChatType.Yell] = ColourUtil.ComponentsToRgba(240, 173, 78), // Warning #F0AD4E [ChatType.Shout] = ColourUtil.ComponentsToRgba(249, 115, 22), // Brand Ember #F97316 - - // Gruppen-Channels — Success/Ember-dark/Cyan [ChatType.Party] = ColourUtil.ComponentsToRgba(92, 184, 92), // Success #5CB85C [ChatType.Alliance] = ColourUtil.ComponentsToRgba(232, 93, 4), // Ember-dark #E85D04 [ChatType.FreeCompany] = ColourUtil.ComponentsToRgba(0, 190, 210), // Brand Cyan [ChatType.NoviceNetwork] = ColourUtil.ComponentsToRgba(77, 217, 232), // Cyan-light - - // Linkshells 1-8 — über das ganze Brand-Spektrum verteilt [ChatType.Linkshell1] = ColourUtil.ComponentsToRgba(251, 146, 60), // Ember-light #FB923C [ChatType.Linkshell2] = ColourUtil.ComponentsToRgba(240, 173, 78), // Warning [ChatType.Linkshell3] = ColourUtil.ComponentsToRgba(92, 184, 92), // Success @@ -218,8 +202,6 @@ public static class ChatColourPresets [ChatType.Linkshell6] = ColourUtil.ComponentsToRgba(0, 151, 167), // Cyan-dark [ChatType.Linkshell7] = ColourUtil.ComponentsToRgba(249, 115, 22), // Brand Ember [ChatType.Linkshell8] = ColourUtil.ComponentsToRgba(217, 83, 79), // Danger #D9534F - - // CrossWorld-Linkshells 1-8 — dunklere/sattersere Varianten [ChatType.CrossLinkshell1] = ColourUtil.ComponentsToRgba(232, 93, 4), // Ember-dark [ChatType.CrossLinkshell2] = ColourUtil.ComponentsToRgba(200, 140, 50), // Warning-dark [ChatType.CrossLinkshell3] = ColourUtil.ComponentsToRgba(60, 140, 60), // Success-dark @@ -231,31 +213,20 @@ public static class ChatColourPresets }; } - // Bonus preset — Night Blue, KAZAMA-Stimmungs-Theme aus - // /mnt/HDD-Data1/Obsidian/Vault/Systeme/KAZAMA/Theming/Night Blue + Indigo Violet Themes.md - // Klassisch, kühl, technisch — Marineblau-Tiefe ohne Lila-Anteil. - // Bewusst NICHT als Brand-Preset markiert (Vault-Boundary): die KAZAMA-Themes - // sind persönliche Stimmungs-Themes, nicht Teil des Hellion-Brand-Systems. + // Night Blue — cool nautical theme, deep navy without purple. private static IReadOnlyDictionary BuildNightBlue() { return new Dictionary { - // Standard / Tell — Royal Blue Akzent-Familie [ChatType.Say] = ColourUtil.ComponentsToRgba(230, 237, 247), // text-primary [ChatType.TellIncoming] = ColourUtil.ComponentsToRgba(106, 176, 255), // akzent-hot [ChatType.TellOutgoing] = ColourUtil.ComponentsToRgba(74, 144, 226), // akzent-primary - - // Laute Channels — Warning/Danger Status-Töne [ChatType.Yell] = ColourUtil.ComponentsToRgba(255, 184, 74), // warning [ChatType.Shout] = ColourUtil.ComponentsToRgba(255, 92, 122), // danger - - // Gruppen — Success/Akzent-Variations [ChatType.Party] = ColourUtil.ComponentsToRgba(61, 220, 151), // success [ChatType.Alliance] = ColourUtil.ComponentsToRgba(255, 144, 100), // warm-orange-light [ChatType.FreeCompany] = ColourUtil.ComponentsToRgba(74, 144, 226), // akzent-primary [ChatType.NoviceNetwork] = ColourUtil.ComponentsToRgba(140, 160, 191), // text-dim - - // Linkshells 1-8 — über Spektrum verteilt [ChatType.Linkshell1] = ColourUtil.ComponentsToRgba(255, 184, 74), [ChatType.Linkshell2] = ColourUtil.ComponentsToRgba(255, 144, 100), [ChatType.Linkshell3] = ColourUtil.ComponentsToRgba(255, 220, 130), @@ -264,8 +235,6 @@ public static class ChatColourPresets [ChatType.Linkshell6] = ColourUtil.ComponentsToRgba(100, 200, 220), [ChatType.Linkshell7] = ColourUtil.ComponentsToRgba(106, 176, 255), [ChatType.Linkshell8] = ColourUtil.ComponentsToRgba(140, 160, 191), - - // CrossWorld-Linkshells — gedämpfte Variants [ChatType.CrossLinkshell1] = ColourUtil.ComponentsToRgba(200, 130, 50), [ChatType.CrossLinkshell2] = ColourUtil.ComponentsToRgba(220, 110, 80), [ChatType.CrossLinkshell3] = ColourUtil.ComponentsToRgba(200, 180, 60), @@ -277,30 +246,20 @@ public static class ChatColourPresets }; } - // Bonus preset — Indigo Violet, KAZAMA-Stimmungs-Theme aus demselben - // Vault-Doc. Warm-mystisch, "Galaxy/Glitter/Nordlicht" — tiefes Indigo - // mit kräftigem Violet-Akzent. Persönlicher Favorit (siehe Vault). - // Auch nicht als Brand-Preset (siehe NightBlue-Note oben). + // Indigo Violet — warm-mystic theme, deep indigo with violet accent. private static IReadOnlyDictionary BuildIndigoViolet() { return new Dictionary { - // Standard / Tell — Royal Violet Akzent-Familie - [ChatType.Say] = ColourUtil.ComponentsToRgba(240, 230, 255), // text-primary (light lavender) + [ChatType.Say] = ColourUtil.ComponentsToRgba(240, 230, 255), // text-primary [ChatType.TellIncoming] = ColourUtil.ComponentsToRgba(176, 124, 255), // akzent-hot [ChatType.TellOutgoing] = ColourUtil.ComponentsToRgba(139, 77, 222), // akzent-primary - - // Laute Channels — geteilt mit Night Blue (Status-Farben) [ChatType.Yell] = ColourUtil.ComponentsToRgba(255, 184, 74), [ChatType.Shout] = ColourUtil.ComponentsToRgba(255, 92, 122), - - // Gruppen [ChatType.Party] = ColourUtil.ComponentsToRgba(61, 220, 151), [ChatType.Alliance] = ColourUtil.ComponentsToRgba(255, 144, 100), [ChatType.FreeCompany] = ColourUtil.ComponentsToRgba(139, 77, 222), // akzent-primary [ChatType.NoviceNetwork] = ColourUtil.ComponentsToRgba(168, 144, 208), // text-dim - - // Linkshells 1-8 [ChatType.Linkshell1] = ColourUtil.ComponentsToRgba(255, 184, 74), [ChatType.Linkshell2] = ColourUtil.ComponentsToRgba(255, 144, 100), [ChatType.Linkshell3] = ColourUtil.ComponentsToRgba(255, 220, 130), @@ -309,8 +268,6 @@ public static class ChatColourPresets [ChatType.Linkshell6] = ColourUtil.ComponentsToRgba(139, 77, 222), [ChatType.Linkshell7] = ColourUtil.ComponentsToRgba(130, 90, 200), [ChatType.Linkshell8] = ColourUtil.ComponentsToRgba(168, 144, 208), - - // CrossWorld-Linkshells [ChatType.CrossLinkshell1] = ColourUtil.ComponentsToRgba(200, 130, 50), [ChatType.CrossLinkshell2] = ColourUtil.ComponentsToRgba(220, 110, 80), [ChatType.CrossLinkshell3] = ColourUtil.ComponentsToRgba(200, 180, 60), diff --git a/HellionChat/SelfTests/ThemeSwitchSelfTestStep.cs b/HellionChat/SelfTests/ThemeSwitchSelfTestStep.cs index 3eb6652..7210bad 100644 --- a/HellionChat/SelfTests/ThemeSwitchSelfTestStep.cs +++ b/HellionChat/SelfTests/ThemeSwitchSelfTestStep.cs @@ -4,13 +4,9 @@ using HellionChat.Themes; namespace HellionChat.SelfTests; -// Validates the runtime theme-switch contract from the user side. The -// caller toggles the active theme via Settings -> Theme & Layout, the -// step polls ThemeRegistry.Active per frame and only passes once the -// slug has moved away from the initial value and back. The ABGR cache -// is sanity-checked on every frame: a freshly switched theme must carry -// a populated cache, otherwise Switch() forgot the recompute and the UI -// would still draw, just with all-transparent slots. +// Validates the runtime theme-switch contract: polls ThemeRegistry.Active +// per frame until the slug moves away and back, then sanity-checks that +// the ABGR cache was recomputed on switch. internal sealed class ThemeSwitchSelfTestStep : ISelfTestStep { private readonly Plugin plugin; @@ -73,9 +69,8 @@ internal sealed class ThemeSwitchSelfTestStep : ISelfTestStep this.switchedAway = false; } - // Any non-zero slot proves the cache was actually recomputed for the - // current theme. We don't compare against a reference, because custom - // themes can legitimately share slot values with a built-in. + // Any non-zero slot confirms the cache was recomputed — no reference + // comparison since custom themes can share slot values with built-ins. private static bool HasPopulatedCache(Theme theme) { var cache = theme.AbgrCache; diff --git a/HellionChat/Themes/Builtin/SynthwaveSunset.cs b/HellionChat/Themes/Builtin/SynthwaveSunset.cs index 3fe4426..6fa0492 100644 --- a/HellionChat/Themes/Builtin/SynthwaveSunset.cs +++ b/HellionChat/Themes/Builtin/SynthwaveSunset.cs @@ -10,7 +10,7 @@ internal static class SynthwaveSunset new( Slug: Slug, Name: "Synthwave Sunset", - Author: "Hellion Forge", + Author: "Zoe Moon", Description: "Hot Magenta + Cyan on midnight violet. 80s neon-grid vibes for late-night raids.", Colors: new ThemeColors( PrimaryDark: ColourUtil.HexToRgba("#C71585"), diff --git a/HellionChat/Themes/ThemeChatColors.cs b/HellionChat/Themes/ThemeChatColors.cs index c1518b4..0d2ed6a 100644 --- a/HellionChat/Themes/ThemeChatColors.cs +++ b/HellionChat/Themes/ThemeChatColors.cs @@ -2,8 +2,6 @@ using HellionChat.Code; namespace HellionChat.Themes; -// Optional pro Theme. Wenn ein Theme ChatColors mitliefert, kann der -// User sie per Klick im Themes-Tab auf Configuration.ChatColours anwenden. -// Ein Theme ohne ChatColors (z.B. chat2-classic) lässt die User-Channel- -// Farben unverändert. +// Optional per-theme chat colours applied to Configuration.ChatColours on user request. +// Themes without this leave channel colours untouched. public sealed record ThemeChatColors(IReadOnlyDictionary Channels); diff --git a/HellionChat/Themes/ThemeColors.cs b/HellionChat/Themes/ThemeColors.cs index 96f99d4..c131d43 100644 --- a/HellionChat/Themes/ThemeColors.cs +++ b/HellionChat/Themes/ThemeColors.cs @@ -1,6 +1,6 @@ namespace HellionChat.Themes; -// Color-Werte als 0xRRGGBBAA, RgbaToAbgr handled den Byte-Swap zu ImGui. +// Colour values as 0xRRGGBBAA — RgbaToAbgr handles the byte-swap for ImGui. public sealed record ThemeColors( uint PrimaryDark, uint Primary, diff --git a/HellionChat/Themes/ThemeJsonLoader.cs b/HellionChat/Themes/ThemeJsonLoader.cs index 4c53c0e..88a4c32 100644 --- a/HellionChat/Themes/ThemeJsonLoader.cs +++ b/HellionChat/Themes/ThemeJsonLoader.cs @@ -66,10 +66,8 @@ internal static class ThemeJsonLoader var dict = new Dictionary(); foreach (var prop in el.EnumerateObject()) { - // Property-Name ist der ChatType-Name als String (z.B. "Say", "Tell"), - // Value ist Hex wie bei den Theme-Colors. Unbekannte Channel-Names - // werden still übersprungen — Forward-Compat falls SE neue Channels - // einführt. + // Property name is the ChatType name (e.g. "Say", "Tell"), value is hex like theme colours. + // Unknown channel names are silently skipped for forward-compat with future SE channels. if ( !Enum.TryParse( prop.Name, diff --git a/HellionChat/Themes/ThemeLayout.cs b/HellionChat/Themes/ThemeLayout.cs index 116c2a3..d195e91 100644 --- a/HellionChat/Themes/ThemeLayout.cs +++ b/HellionChat/Themes/ThemeLayout.cs @@ -1,6 +1,6 @@ namespace HellionChat.Themes; -// Layout-Werte spiegeln die ImGuiStyleVar-Slots, die HellionStyle pusht. +// Layout values mirror the ImGuiStyleVar slots pushed by HellionStyle. public sealed record ThemeLayout( float WindowRounding, float ChildRounding, diff --git a/HellionChat/Themes/ThemeRegistry.cs b/HellionChat/Themes/ThemeRegistry.cs index 5ff7407..ac26dd7 100644 --- a/HellionChat/Themes/ThemeRegistry.cs +++ b/HellionChat/Themes/ThemeRegistry.cs @@ -29,7 +29,7 @@ public sealed class ThemeRegistry { SynthwaveSunset.Slug, SynthwaveSunset.Build() }, }; - // Centralised so the ten .Build() factories stay free of cache plumbing. + // Centralised so Build() factories stay free of cache plumbing. foreach (var theme in _builtIns.Values) theme.RecomputeAbgrCache(); @@ -58,14 +58,13 @@ public sealed class ThemeRegistry public void Switch(string slug) { var theme = Get(slug); - // Defensive — idempotent and cheap, so any future theme source - // that forgets the cache fill still ends up with a populated one. + // Defensive — ensures any future theme source always gets a populated cache. theme.RecomputeAbgrCache(); _active = theme; } - // 0x80070020 = SHARING_VIOLATION, 0x80070021 = LOCK_VIOLATION. Other - // IO failures are permanent and get the theme dropped instead of retried. + // 0x80070020 = SHARING_VIOLATION, 0x80070021 = LOCK_VIOLATION. + // Other IO failures are permanent — theme is dropped instead of retried. internal static bool IsRecoverableFileLock(Exception? ex) { if (ex is not IOException io) @@ -74,9 +73,8 @@ public sealed class ThemeRegistry return code == 0x80070020u || code == 0x80070021u; } - // Custom-Themes werden lazy aus dem Verzeichnis geladen, Cache mit - // LastWriteTime-Token. Eine geänderte JSON wird beim nächsten Lookup - // neu eingelesen. + // Custom themes are loaded lazily, cached by LastWriteTime. + // A changed JSON is reloaded on the next lookup. private Theme? LoadCustomBySlug(string slug) { if (_customThemesDir is null) @@ -115,8 +113,7 @@ public sealed class ThemeRegistry } catch (Exception ex) when (IsRecoverableFileLock(ex)) { - // Editor mid-save: keep the cached snapshot, leave the stamp - // alone so the next refresh retries automatically. + // Editor mid-save: keep last known good, retry on next refresh. Plugin.Log.Debug( $"Custom theme {Path.GetFileName(path)} is locked, keeping last known good" ); diff --git a/HellionChat/Themes/ThemeTypography.cs b/HellionChat/Themes/ThemeTypography.cs index b1fbf3a..9f7a981 100644 --- a/HellionChat/Themes/ThemeTypography.cs +++ b/HellionChat/Themes/ThemeTypography.cs @@ -1,7 +1,6 @@ namespace HellionChat.Themes; -// Optional pro Theme. v1.1.0 nutzt das nicht aktiv; ist als Erweiterungspunkt -// für zukünftige Theme-Slots vorbereitet. +// Optional per-theme; reserved as an extension point for future theme slots. public sealed record ThemeTypography( float? OverrideGlobalFontSizePt = null, float? OverrideSymbolsFontSizePt = null