From 47a49de8c074826d22620c2093b9b90e65e6e073 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 00:50:45 +0200 Subject: [PATCH] feat(tell-router): auto-open incoming tells per TellAutoOpenMode --- .../Hosting/InitHostedServices.cs | 12 +++ HellionChat/PluginHostFactory.cs | 8 +- HellionChat/Services/TellRouterService.cs | 85 ++++++++++++++++--- 3 files changed, 91 insertions(+), 14 deletions(-) diff --git a/HellionChat/Infrastructure/Hosting/InitHostedServices.cs b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs index 3929753..65d36a8 100644 --- a/HellionChat/Infrastructure/Hosting/InitHostedServices.cs +++ b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs @@ -101,6 +101,18 @@ internal sealed class AutoTellTabsServiceInitHostedService(AutoTellTabsService s public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } +internal sealed class TellRouterServiceInitHostedService(Services.TellRouterService service) + : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + service.Initialize(); + return Task.CompletedTask; + } + + 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) diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 1fb745f..06a9c32 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -122,7 +122,7 @@ internal static class PluginHostFactory sp.GetRequiredService() )); services.AddSingleton(sp => new Services.TellRouterService( - sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService>() )); @@ -369,6 +369,12 @@ internal static class PluginHostFactory services.AddHostedService(sp => new AutoTellTabsServiceInitHostedService( sp.GetRequiredService() )); + // Must come AFTER AutoTell's registration: both subscribe MessageProcessed, + // and AutoTell subscribing first lets the router's IsOpen-guard see the + // already-opened pop-out (FIFO framework-tick ordering, no double-pop). + services.AddHostedService(sp => new TellRouterServiceInitHostedService( + sp.GetRequiredService() + )); services.AddHostedService( sp => new Infrastructure.Hosting.FailedTellNotifierInitHostedService( sp.GetRequiredService() diff --git a/HellionChat/Services/TellRouterService.cs b/HellionChat/Services/TellRouterService.cs index 38d71e0..5f2b989 100644 --- a/HellionChat/Services/TellRouterService.cs +++ b/HellionChat/Services/TellRouterService.cs @@ -1,32 +1,91 @@ -using Dalamud.Game.Chat; -using Dalamud.Plugin.Services; +using HellionChat.Code; +using HellionChat.Util; using Microsoft.Extensions.Logging; namespace HellionChat.Services; -// Skeleton for the upcoming auto-open routing layer. Subscribes to IChatGui -// up front so the DI graph and Plugin.cs registration stay frozen — when -// the routing logic lands, it drops into OnChatMessage without touching -// anything else. +// Routes an incoming tell to the configured TellAutoOpenMode (Off/Sidebar/ +// TopTab/Popout). Decoupled from AutoTellTabsService (Flo decision 2026-06-15): +// that service owns tab CREATION + lifecycle; this only REVEALS/pops the tab it +// finds. Popout guards on pool.IsOpen so it never double-pops a tab the +// AutoTellTabsOpenAsPopout path already opened. Subscribes to the resolved +// MessageManager.MessageProcessed stream (partner already extracted), not the raw +// IChatGui event, and defers the reveal one tick so the tab exists regardless of +// subscriber order. Wired by TellRouterServiceInitHostedService. internal sealed class TellRouterService : IDisposable { - private readonly IChatGui _chatGui; + private readonly MessageManager _messageManager; private readonly ILogger _logger; + private bool _initialized; - public TellRouterService(IChatGui chatGui, ILogger logger) + public TellRouterService(MessageManager messageManager, ILogger logger) { - _chatGui = chatGui; + _messageManager = messageManager; _logger = logger; - _chatGui.ChatMessageUnhandled += OnChatMessage; + } + + public void Initialize() + { + if (_initialized) + return; + + _messageManager.MessageProcessed += OnMessageProcessed; + _initialized = true; + _logger.LogDebug("TellRouterService online; routing incoming tells by TellAutoOpenMode."); } public void Dispose() { - _chatGui.ChatMessageUnhandled -= OnChatMessage; + if (!_initialized) + return; + + _messageManager.MessageProcessed -= OnMessageProcessed; + _initialized = false; } - private void OnChatMessage(IChatMessage message) + private void OnMessageProcessed(Message message) { - // Intentional no-op until the routing implementation lands. + var mode = Plugin.Config.TellAutoOpenMode; + if (mode == TellAutoOpenMode.Off) + return; + + if (message.Code.Type != ChatType.TellIncoming) + return; + + // Partner = sender for an incoming tell. Same payload idiom AutoTellTabs uses + // (AutoTellTabsService.ExtractTellPartner), so the lookup never diverges. + var partner = + ChunkUtil.TryGetPlayerPayload(message.Sender) + ?? ChunkUtil.TryGetPlayerPayload(message.SenderSource); + if (partner == null) + return; + + var name = partner.PlayerName; + var world = partner.World.RowId; + + // Defer the reveal to the next framework tick. AutoTellTabsService also + // handles this MessageProcessed (synchronously); by the next tick the tab + // exists regardless of subscription order, and the reveal (ActivateTab / pool + // mutation) is serialized with Draw (reference_dalamud_framework_thread). + Plugin.Framework.RunOnFrameworkThread(() => + { + var tab = AutoTellTabsService.FindTempTab(name, world); + if (tab == null) + return; // nothing to reveal (auto-tell-tabs off -> no tab created) + + switch (mode) + { + case TellAutoOpenMode.Sidebar: + case TellAutoOpenMode.TopTab: + Plugin.Instance.MainWindow?.ActivateTab(tab); + break; + case TellAutoOpenMode.Popout: + // IsOpen-guard: don't double-pop a tab the AutoTellTabsOpenAsPopout + // path already opened (the two switches stay decoupled). + if (!Plugin.Instance.ChannelPopoutPool.IsOpen(tab.Identifier)) + Plugin.Instance.ChannelPopoutPool.TryOpen(tab); + break; + } + }); } }