295 lines
9.1 KiB
C#
295 lines
9.1 KiB
C#
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<ChatTypingDetectionService> _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<ChatTypingDetectionService> 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<string>(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");
|
|
}
|
|
}
|
|
}
|