using System; using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; using Dalamud.Plugin.Services; using Dalamud.Utility; using Dalamud.Game.Text; using FFXIVClientStructs.FFXIV.Component.GUI; using FFXIVClientStructs.FFXIV.Client.UI.Shell; using Microsoft.Extensions.Logging; using MareSynchronos.PlayerData.Pairs; using MareSynchronos.WebAPI; namespace MareSynchronos.Services; public sealed class ChatTypingDetectionService : IDisposable { private readonly ILogger _logger; private readonly IFramework _framework; private readonly IClientState _clientState; private readonly IGameGui _gameGui; private readonly ChatService _chatService; private readonly TypingIndicatorStateService _typingStateService; private readonly ApiController _apiController; private readonly PairManager _pairManager; private readonly IPartyList _partyList; private string _lastChatText = string.Empty; private bool _isTyping; private bool _notifyingRemote; private bool _serverSupportWarnLogged; private bool _remoteNotificationsEnabled; public ChatTypingDetectionService(ILogger logger, IFramework framework, IClientState clientState, IGameGui gameGui, ChatService chatService, PairManager pairManager, IPartyList partyList, TypingIndicatorStateService typingStateService, ApiController apiController) { _logger = logger; _framework = framework; _clientState = clientState; _gameGui = gameGui; _chatService = chatService; _pairManager = pairManager; _partyList = partyList; _typingStateService = typingStateService; _apiController = apiController; _framework.Update += OnFrameworkUpdate; _logger.LogInformation("ChatTypingDetectionService initialized"); } public void Dispose() { _framework.Update -= OnFrameworkUpdate; ResetTypingState(); } private void OnFrameworkUpdate(IFramework framework) { try { if (!_clientState.IsLoggedIn) { ResetTypingState(); return; } if (!TryGetChatInput(out var chatText) || string.IsNullOrEmpty(chatText)) { ResetTypingState(); return; } if (IsIgnoredCommand(chatText)) { ResetTypingState(); return; } var notifyRemote = ShouldNotifyRemote(); UpdateRemoteNotificationLogState(notifyRemote); if (!notifyRemote && _notifyingRemote) { _chatService.ClearTypingState(); _notifyingRemote = false; } if (!_isTyping || !string.Equals(chatText, _lastChatText, StringComparison.Ordinal)) { if (notifyRemote) { _chatService.NotifyTypingKeystroke(); _notifyingRemote = true; } _typingStateService.SetSelfTypingLocal(true); _isTyping = true; } _lastChatText = chatText; } catch (Exception ex) { _logger.LogTrace(ex, "ChatTypingDetectionService tick failed"); } } private void ResetTypingState() { if (!_isTyping) { _lastChatText = string.Empty; return; } _isTyping = false; _lastChatText = string.Empty; _chatService.ClearTypingState(); _notifyingRemote = false; _typingStateService.SetSelfTypingLocal(false); } private static bool IsIgnoredCommand(string chatText) { if (string.IsNullOrWhiteSpace(chatText)) return false; var trimmed = chatText.TrimStart(); if (!trimmed.StartsWith('/')) return false; var firstTokenEnd = trimmed.IndexOf(' '); var command = firstTokenEnd >= 0 ? trimmed[..firstTokenEnd] : trimmed; command = command.TrimEnd(); var comparison = StringComparison.OrdinalIgnoreCase; return command.StartsWith("/tell", comparison) || command.StartsWith("/t", comparison) || command.StartsWith("/xllog", comparison) || command.StartsWith("/umbra", comparison) || command.StartsWith("/fc", comparison) || command.StartsWith("/freecompany", comparison); } private unsafe bool ShouldNotifyRemote() { try { var supportsTypingState = _apiController.SystemInfoDto.SupportsTypingState; var connected = _apiController.IsConnected; if (!connected || !supportsTypingState) { if (!_serverSupportWarnLogged) { _logger.LogDebug("TypingDetection: server support unavailable (connected={connected}, supports={supports})", connected, supportsTypingState); _serverSupportWarnLogged = true; } return false; } _serverSupportWarnLogged = false; var shellModule = RaptureShellModule.Instance(); if (shellModule == null) { _logger.LogDebug("TypingDetection: shell module null"); return true; } var chatType = (XivChatType)shellModule->ChatType; switch (chatType) { case XivChatType.Say: case XivChatType.Shout: case XivChatType.Yell: return true; case XivChatType.Party: case XivChatType.CrossParty: var eligible = PartyContainsPairedMember(); return eligible; case XivChatType.Debug: return true; default: _logger.LogTrace("TypingDetection: channel {type} rejected", chatType); return false; } } catch (Exception ex) { _logger.LogTrace(ex, "ChatTypingDetectionService: failed to evaluate chat channel"); } return true; } private bool PartyContainsPairedMember() { try { var pairedNames = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var pair in _pairManager.GetOnlineUserPairs()) { if (!string.IsNullOrEmpty(pair.PlayerName)) pairedNames.Add(pair.PlayerName); } if (pairedNames.Count == 0) { _logger.LogDebug("TypingDetection: no paired names online"); return false; } foreach (var member in _partyList) { var name = member?.Name?.TextValue; if (string.IsNullOrEmpty(name)) continue; if (pairedNames.Contains(name)) { return true; } } } catch (Exception ex) { _logger.LogDebug(ex, "ChatTypingDetectionService: failed to check party composition"); } _logger.LogDebug("TypingDetection: no paired members in party"); return false; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private unsafe bool TryGetChatInput(out string chatText) { chatText = string.Empty; var addon = _gameGui.GetAddonByName("ChatLog", 1); if (addon.Address == nint.Zero) return false; var chatLog = (AtkUnitBase*)addon.Address; if (chatLog == null || !chatLog->IsVisible) return false; var textInputNode = chatLog->UldManager.NodeList[16]; if (textInputNode == null) return false; var componentNode = textInputNode->GetAsAtkComponentNode(); if (componentNode == null || componentNode->Component == null) return false; var cursorNode = componentNode->Component->UldManager.NodeList[14]; if (cursorNode == null) return false; var cursorVisible = cursorNode->IsVisible(); if (!cursorVisible) { return false; } var chatInputNode = componentNode->Component->UldManager.NodeList[1]; if (chatInputNode == null) return false; var textNode = chatInputNode->GetAsAtkTextNode(); if (textNode == null) return false; var rawText = textNode->GetText(); if (rawText == null) return false; chatText = rawText.AsDalamudSeString().ToString(); return true; } private void UpdateRemoteNotificationLogState(bool notifyRemote) { if (notifyRemote && !_remoteNotificationsEnabled) { _remoteNotificationsEnabled = true; _logger.LogInformation("TypingDetection: remote notifications enabled"); } else if (!notifyRemote && _remoteNotificationsEnabled) { _remoteNotificationsEnabled = false; _logger.LogInformation("TypingDetection: remote notifications disabled"); } } }