diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index 3200a5b..8594e46 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -187,6 +187,8 @@ public class Configuration : IPluginConfiguration public bool CollapseKeepUniqueLinks; public bool SymbolPickerEnabled = true; public bool PlaySounds = true; + // UI-2: toast when a tell the user sent could not be delivered. + public bool NotifyFailedTell = true; public bool KeepInputFocus = true; public int MaxLinesToRender = 2_500; // 1-10000 public bool Use24HourClock = true; @@ -282,6 +284,7 @@ public class Configuration : IPluginConfiguration CollapseKeepUniqueLinks = other.CollapseKeepUniqueLinks; SymbolPickerEnabled = other.SymbolPickerEnabled; PlaySounds = other.PlaySounds; + NotifyFailedTell = other.NotifyFailedTell; KeepInputFocus = other.KeepInputFocus; MaxLinesToRender = other.MaxLinesToRender; Use24HourClock = other.Use24HourClock; diff --git a/HellionChat/Infrastructure/Hosting/InitHostedServices.cs b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs index b7729f6..6dfbe52 100644 --- a/HellionChat/Infrastructure/Hosting/InitHostedServices.cs +++ b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs @@ -1,4 +1,5 @@ using Dalamud.Plugin; +using HellionChat.Integrations; using HellionChat.Ipc; using HellionChat.Themes; using Microsoft.Extensions.Hosting; @@ -85,3 +86,18 @@ internal sealed class AutoTellTabsServiceInitHostedService(AutoTellTabsService s public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } + +// Eager-resolve trigger: resolving FailedTellNotifier in this adapter's ctor +// enables its game hook during host startup. StartAsync itself is a no-op. +internal sealed class FailedTellNotifierInitHostedService( + FailedTellNotifier notifier) : IHostedService +{ + // No-op adapter: the ctor dependency above is the actual eager-resolve + // trigger. Field kept to match the IpcManager/TypingIpc/ExtraChat no-op + // adapters and to avoid the CS9113 unread-parameter warning. + private readonly FailedTellNotifier _notifier = notifier; + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/HellionChat/Integrations/FailedTellNotifier.cs b/HellionChat/Integrations/FailedTellNotifier.cs new file mode 100644 index 0000000..9b01184 --- /dev/null +++ b/HellionChat/Integrations/FailedTellNotifier.cs @@ -0,0 +1,74 @@ +using System; +using Dalamud.Hooking; +using Dalamud.Interface.ImGuiNotification; +using FFXIVClientStructs.FFXIV.Client.System.String; +using FFXIVClientStructs.FFXIV.Client.UI.Misc; +using HellionChat._Helpers; +using HellionChat.Resources; +using HellionChat.Util; +using Microsoft.Extensions.Logging; + +namespace HellionChat.Integrations; + +// UI-2: a minimal, failed-tell-specific game hook. A locale-robust "tell +// failed" signal is not reachable over the processed message stream (Message +// carries no LogMessage row id, ChatCode 60 is too broad). This hooks the one +// ShowLogMessageString overload and toasts on a pinned id set. It is NOT the +// broad ad-block hook layer — that stays v1.5.6. +internal sealed class FailedTellNotifier : IDisposable +{ + private readonly ILogger _logger; + private readonly Hook? _hook; + + public unsafe FailedTellNotifier(ILogger logger) + { + _logger = logger; + + // Creating/enabling a hook is safe off the framework thread (the + // ctor runs during host startup on the framework thread, + // eager-resolved via FailedTellNotifierInitHostedService). + _hook = Plugin.GameInteropProvider + .HookFromAddress( + RaptureLogModule.MemberFunctionPointers.ShowLogMessageString, + ShowLogMessageStringDetour); + _hook.Enable(); + } + + private unsafe void ShowLogMessageStringDetour( + RaptureLogModule* module, uint logMessageId, Utf8String* value) + { + try + { + // DISCOVERY (Task 2 Step 9): while the id set is empty, log every + // call so a failed tell can be triggered in-game and its id read. + // Remove this block once FailedTellLogMessageIds is pinned. + _logger.LogInformation( + "ShowLogMessageString id={Id} value={Value}", + logMessageId, + value is null ? "" : value->ToString()); + + if (FailedTellMatcher.ShouldNotify( + logMessageId, Plugin.Config.NotifyFailedTell, + FailedTellMatcher.FailedTellLogMessageIds)) + { + var recipient = value is null ? string.Empty : value->ToString(); + var content = string.IsNullOrEmpty(recipient) + ? HellionStrings.FailedTell_Notification_Generic + : string.Format(HellionStrings.FailedTell_Notification_Named, recipient); + WrapperUtil.AddNotification(content, NotificationType.Warning); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "FailedTellNotifier detour threw"); + } + + _hook!.Original(module, logMessageId, value); + } + + public void Dispose() + { + _hook?.Disable(); + _hook?.Dispose(); + } +} diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index f0c3375..c3656c6 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -107,6 +107,8 @@ internal static class PluginHostFactory sp.GetRequiredService>(), sp.GetRequiredService() )); + services.AddSingleton(sp => new Integrations.FailedTellNotifier( + sp.GetRequiredService>())); services.AddSingleton(sp => new MessageManager( sp.GetRequiredService(), @@ -172,6 +174,8 @@ internal static class PluginHostFactory services.AddHostedService(sp => new AutoTellTabsServiceInitHostedService( sp.GetRequiredService() )); + services.AddHostedService(sp => new Infrastructure.Hosting.FailedTellNotifierInitHostedService( + sp.GetRequiredService())); } } diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index d02a74c..8d55b00 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -451,4 +451,20 @@ internal class HellionStrings internal static string Settings_QuickPicker_Tabs_Header => Get(nameof(Settings_QuickPicker_Tabs_Header)); internal static string Settings_ThemeAndLayout_ReduceMotion_Name => Get(nameof(Settings_ThemeAndLayout_ReduceMotion_Name)); internal static string Settings_ThemeAndLayout_ReduceMotion_Description => Get(nameof(Settings_ThemeAndLayout_ReduceMotion_Description)); + + // Hellion Chat — v1.5.5 UI-2 Failed Tell Notification + internal static string FailedTell_Notification_Generic => Get(nameof(FailedTell_Notification_Generic)); + internal static string FailedTell_Notification_Named => Get(nameof(FailedTell_Notification_Named)); + internal static string Settings_Chat_NotifyFailedTell_Name => Get(nameof(Settings_Chat_NotifyFailedTell_Name)); + internal static string Settings_Chat_NotifyFailedTell_Description => Get(nameof(Settings_Chat_NotifyFailedTell_Description)); + + // Hellion Chat — v1.5.5 UI-3 Per-Tab Notification Sound + internal static string Tabs_NotificationSound_Enable_Name => Get(nameof(Tabs_NotificationSound_Enable_Name)); + internal static string Tabs_NotificationSound_Description => Get(nameof(Tabs_NotificationSound_Description)); + internal static string Tabs_NotificationSound_Option => Get(nameof(Tabs_NotificationSound_Option)); + + // Hellion Chat — v1.5.5 UI-5a Scroll-to-bottom + UI-6 Linking + internal static string ChatLog_ScrollToBottom_Tooltip => Get(nameof(ChatLog_ScrollToBottom_Tooltip)); + internal static string ChatLog_Insert_MapFlag => Get(nameof(ChatLog_Insert_MapFlag)); + internal static string ChatLog_Insert_ItemLink => Get(nameof(ChatLog_Insert_ItemLink)); } diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index 25b64ef..d7acca5 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1048,4 +1048,40 @@ Disables the theme crossfade, the sidebar and card-row hover animations, and the unread-tab pulse. Theme switches and hover states apply instantly instead. + + + + A tell could not be delivered. + + + Tell to {0} could not be delivered. + + + Notify on failed tell + + + Show a toast when a tell you sent could not be delivered (recipient offline, in an instance, or blocking you). + + + + + Notification sound + + + Play a sound when a message arrives in this tab while you are looking at a different tab. Respects the global sound toggle. + + + Sound + + + + + Jump to the latest message + + + Insert map flag <flag> + + + Insert linked item <item> + diff --git a/HellionChat/Ui/SettingsTabs/Chat.cs b/HellionChat/Ui/SettingsTabs/Chat.cs index 3115f66..d7fc96b 100644 --- a/HellionChat/Ui/SettingsTabs/Chat.cs +++ b/HellionChat/Ui/SettingsTabs/Chat.cs @@ -145,6 +145,12 @@ internal sealed class Chat : ISettingsTab ref Mutable.SymbolPickerEnabled ); ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_SymbolPicker_Enable_Description); + + ImGui.Checkbox( + HellionStrings.Settings_Chat_NotifyFailedTell_Name, + ref Mutable.NotifyFailedTell + ); + ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_NotifyFailedTell_Description); } } diff --git a/HellionChat/_Helpers/FailedTellMatcher.cs b/HellionChat/_Helpers/FailedTellMatcher.cs new file mode 100644 index 0000000..eba16aa --- /dev/null +++ b/HellionChat/_Helpers/FailedTellMatcher.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace HellionChat._Helpers; + +// UI-2 pure decision helper for failed-tell detection. The processed message +// stream carries no LogMessage row id, so detection happens at the +// RaptureLogModule level (see FailedTellNotifier). This POCO stays free of +// Dalamud types so the "known id AND enabled" rule is Build-Suite testable. +// TEST-MIRROR: ../../../Hellion Build test/Ui/FailedTellMatcherTests.cs +public static class FailedTellMatcher +{ + // Log-message ids the game raises for a tell that could not be delivered. + // Pinned from discovery (see plan Task 2, Step 9). An empty set means the + // hook never toasts — "no toast over a false toast" until ids are pinned. + public static readonly IReadOnlySet FailedTellLogMessageIds = new HashSet + { + // pinned in Task 2 Step 9 + }; + + public static bool ShouldNotify(uint logMessageId, bool notifyEnabled, IReadOnlySet failedTellIds) + => notifyEnabled && failedTellIds.Contains(logMessageId); +}