Update 0.1.9.2 - Fix BubleChat
This commit is contained in:
Submodule Glamourer.Api updated: 54c1944dc7...7e8505cd6f
2
MareAPI
2
MareAPI
Submodule MareAPI updated: 5fc7969adf...0abb078c21
@@ -153,7 +153,7 @@ public class MarePlugin : MediatorSubscriberBase, IHostedService
|
|||||||
_runtimeServiceScope.ServiceProvider.GetRequiredService<NotificationService>();
|
_runtimeServiceScope.ServiceProvider.GetRequiredService<NotificationService>();
|
||||||
_runtimeServiceScope.ServiceProvider.GetRequiredService<SyncDefaultsService>();
|
_runtimeServiceScope.ServiceProvider.GetRequiredService<SyncDefaultsService>();
|
||||||
_runtimeServiceScope.ServiceProvider.GetRequiredService<ChatService>();
|
_runtimeServiceScope.ServiceProvider.GetRequiredService<ChatService>();
|
||||||
_runtimeServiceScope.ServiceProvider.GetRequiredService<GameChatHooks>();
|
_runtimeServiceScope.ServiceProvider.GetRequiredService<ChatTypingDetectionService>();
|
||||||
_runtimeServiceScope.ServiceProvider.GetRequiredService<GuiHookService>();
|
_runtimeServiceScope.ServiceProvider.GetRequiredService<GuiHookService>();
|
||||||
|
|
||||||
#if !DEBUG
|
#if !DEBUG
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<AssemblyName>UmbraSync</AssemblyName>
|
<AssemblyName>UmbraSync</AssemblyName>
|
||||||
<RootNamespace>UmbraSync</RootNamespace>
|
<RootNamespace>UmbraSync</RootNamespace>
|
||||||
<Version>0.1.9.1</Version>
|
<Version>0.1.9.2</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
collection.AddSingleton<NotificationService>();
|
collection.AddSingleton<NotificationService>();
|
||||||
collection.AddSingleton<TemporarySyncshellNotificationService>();
|
collection.AddSingleton<TemporarySyncshellNotificationService>();
|
||||||
collection.AddSingleton<PartyListTypingService>();
|
collection.AddSingleton<PartyListTypingService>();
|
||||||
|
collection.AddSingleton<TypingIndicatorStateService>();
|
||||||
|
|
||||||
collection.AddSingleton((s) => new MareConfigService(pluginInterface.ConfigDirectory.FullName));
|
collection.AddSingleton((s) => new MareConfigService(pluginInterface.ConfigDirectory.FullName));
|
||||||
collection.AddSingleton((s) => new ServerConfigService(pluginInterface.ConfigDirectory.FullName));
|
collection.AddSingleton((s) => new ServerConfigService(pluginInterface.ConfigDirectory.FullName));
|
||||||
@@ -191,6 +192,7 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
collection.AddScoped<WindowMediatorSubscriberBase, CharaDataHubUi>();
|
collection.AddScoped<WindowMediatorSubscriberBase, CharaDataHubUi>();
|
||||||
collection.AddScoped<WindowMediatorSubscriberBase, EditProfileUi>();
|
collection.AddScoped<WindowMediatorSubscriberBase, EditProfileUi>();
|
||||||
collection.AddScoped<WindowMediatorSubscriberBase, PopupHandler>();
|
collection.AddScoped<WindowMediatorSubscriberBase, PopupHandler>();
|
||||||
|
collection.AddScoped<WindowMediatorSubscriberBase, TypingIndicatorOverlay>();
|
||||||
collection.AddScoped<IPopupHandler, ReportPopupHandler>();
|
collection.AddScoped<IPopupHandler, ReportPopupHandler>();
|
||||||
collection.AddScoped<IPopupHandler, BanUserPopupHandler>();
|
collection.AddScoped<IPopupHandler, BanUserPopupHandler>();
|
||||||
collection.AddScoped<CacheCreationService>();
|
collection.AddScoped<CacheCreationService>();
|
||||||
@@ -202,6 +204,7 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
collection.AddScoped<UiSharedService>();
|
collection.AddScoped<UiSharedService>();
|
||||||
collection.AddScoped<ChatService>();
|
collection.AddScoped<ChatService>();
|
||||||
collection.AddScoped<GuiHookService>();
|
collection.AddScoped<GuiHookService>();
|
||||||
|
collection.AddScoped<ChatTypingDetectionService>();
|
||||||
|
|
||||||
collection.AddHostedService(p => p.GetRequiredService<PluginWatcherService>());
|
collection.AddHostedService(p => p.GetRequiredService<PluginWatcherService>());
|
||||||
collection.AddHostedService(p => p.GetRequiredService<ConfigurationSaveService>());
|
collection.AddHostedService(p => p.GetRequiredService<ConfigurationSaveService>());
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ public class ChatService : DisposableMediatorSubscriberBase
|
|||||||
private readonly object _typingLock = new();
|
private readonly object _typingLock = new();
|
||||||
private CancellationTokenSource? _typingCts;
|
private CancellationTokenSource? _typingCts;
|
||||||
private bool _isTypingAnnounced;
|
private bool _isTypingAnnounced;
|
||||||
|
private DateTime _lastTypingSent = DateTime.MinValue;
|
||||||
private static readonly TimeSpan TypingIdle = TimeSpan.FromSeconds(2);
|
private static readonly TimeSpan TypingIdle = TimeSpan.FromSeconds(2);
|
||||||
|
private static readonly TimeSpan TypingResendInterval = TimeSpan.FromMilliseconds(750);
|
||||||
|
|
||||||
public ChatService(ILogger<ChatService> logger, DalamudUtilService dalamudUtil, MareMediator mediator, ApiController apiController,
|
public ChatService(ILogger<ChatService> logger, DalamudUtilService dalamudUtil, MareMediator mediator, ApiController apiController,
|
||||||
PairManager pairManager, ILoggerFactory loggerFactory, IGameInteropProvider gameInteropProvider, IChatGui chatGui,
|
PairManager pairManager, ILoggerFactory loggerFactory, IGameInteropProvider gameInteropProvider, IChatGui chatGui,
|
||||||
@@ -78,7 +80,8 @@ public class ChatService : DisposableMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
lock (_typingLock)
|
lock (_typingLock)
|
||||||
{
|
{
|
||||||
if (!_isTypingAnnounced)
|
var now = DateTime.UtcNow;
|
||||||
|
if (!_isTypingAnnounced || (now - _lastTypingSent) >= TypingResendInterval)
|
||||||
{
|
{
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
@@ -86,6 +89,7 @@ public class ChatService : DisposableMediatorSubscriberBase
|
|||||||
catch (Exception ex) { _logger.LogDebug(ex, "NotifyTypingKeystroke: failed to send typing=true"); }
|
catch (Exception ex) { _logger.LogDebug(ex, "NotifyTypingKeystroke: failed to send typing=true"); }
|
||||||
});
|
});
|
||||||
_isTypingAnnounced = true;
|
_isTypingAnnounced = true;
|
||||||
|
_lastTypingSent = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
_typingCts?.Cancel();
|
_typingCts?.Cancel();
|
||||||
@@ -113,7 +117,10 @@ public class ChatService : DisposableMediatorSubscriberBase
|
|||||||
lock (_typingLock)
|
lock (_typingLock)
|
||||||
{
|
{
|
||||||
if (!token.IsCancellationRequested)
|
if (!token.IsCancellationRequested)
|
||||||
|
{
|
||||||
_isTypingAnnounced = false;
|
_isTypingAnnounced = false;
|
||||||
|
_lastTypingSent = DateTime.MinValue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -134,6 +141,7 @@ public class ChatService : DisposableMediatorSubscriberBase
|
|||||||
catch (Exception ex) { _logger.LogDebug(ex, "ClearTypingState: failed to send typing=false"); }
|
catch (Exception ex) { _logger.LogDebug(ex, "ClearTypingState: failed to send typing=false"); }
|
||||||
});
|
});
|
||||||
_isTypingAnnounced = false;
|
_isTypingAnnounced = false;
|
||||||
|
_lastTypingSent = DateTime.MinValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
294
MareSynchronos/Services/ChatTypingDetectionService.cs
Normal file
294
MareSynchronos/Services/ChatTypingDetectionService.cs
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
|
using System;
|
||||||
using Dalamud.Game.Gui.NamePlate;
|
using Dalamud.Game.Gui.NamePlate;
|
||||||
using Dalamud.Game.Text.SeStringHandling;
|
using Dalamud.Game.Text.SeStringHandling;
|
||||||
using Dalamud.Game.Text.SeStringHandling.Payloads;
|
using Dalamud.Game.Text.SeStringHandling.Payloads;
|
||||||
using Dalamud.Plugin.Services;
|
using Dalamud.Plugin.Services;
|
||||||
|
using MareSynchronos.API.Dto.User;
|
||||||
using MareSynchronos.MareConfiguration;
|
using MareSynchronos.MareConfiguration;
|
||||||
using MareSynchronos.PlayerData.Pairs;
|
using MareSynchronos.PlayerData.Pairs;
|
||||||
using MareSynchronos.Services.Mediator;
|
using MareSynchronos.Services.Mediator;
|
||||||
using MareSynchronos.UI;
|
using MareSynchronos.UI;
|
||||||
|
using MareSynchronos.WebAPI;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using MareSynchronos.API.Dto.User;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using Dalamud.Game.Text;
|
using Dalamud.Game.Text;
|
||||||
|
|
||||||
namespace MareSynchronos.Services;
|
namespace MareSynchronos.Services;
|
||||||
@@ -22,15 +23,18 @@ public class GuiHookService : DisposableMediatorSubscriberBase
|
|||||||
private readonly IGameConfig _gameConfig;
|
private readonly IGameConfig _gameConfig;
|
||||||
private readonly IPartyList _partyList;
|
private readonly IPartyList _partyList;
|
||||||
private readonly PairManager _pairManager;
|
private readonly PairManager _pairManager;
|
||||||
|
private readonly IClientState _clientState;
|
||||||
|
private readonly ApiController _apiController;
|
||||||
|
private readonly TypingIndicatorStateService _typingStateService;
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, DateTime> _typingUsers = new();
|
|
||||||
private static readonly TimeSpan TypingDisplayTime = TimeSpan.FromSeconds(2);
|
private static readonly TimeSpan TypingDisplayTime = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
private bool _isModified = false;
|
private bool _isModified = false;
|
||||||
private bool _namePlateRoleColorsEnabled = false;
|
private bool _namePlateRoleColorsEnabled = false;
|
||||||
|
|
||||||
public GuiHookService(ILogger<GuiHookService> logger, DalamudUtilService dalamudUtil, MareMediator mediator, MareConfigService configService,
|
public GuiHookService(ILogger<GuiHookService> logger, DalamudUtilService dalamudUtil, MareMediator mediator, MareConfigService configService,
|
||||||
INamePlateGui namePlateGui, IGameConfig gameConfig, IPartyList partyList, PairManager pairManager)
|
INamePlateGui namePlateGui, IGameConfig gameConfig, IPartyList partyList, PairManager pairManager, ApiController apiController,
|
||||||
|
IClientState clientState, TypingIndicatorStateService typingStateService)
|
||||||
: base(logger, mediator)
|
: base(logger, mediator)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -40,6 +44,9 @@ public class GuiHookService : DisposableMediatorSubscriberBase
|
|||||||
_gameConfig = gameConfig;
|
_gameConfig = gameConfig;
|
||||||
_partyList = partyList;
|
_partyList = partyList;
|
||||||
_pairManager = pairManager;
|
_pairManager = pairManager;
|
||||||
|
_apiController = apiController;
|
||||||
|
_clientState = clientState;
|
||||||
|
_typingStateService = typingStateService;
|
||||||
|
|
||||||
_namePlateGui.OnNamePlateUpdate += OnNamePlateUpdate;
|
_namePlateGui.OnNamePlateUpdate += OnNamePlateUpdate;
|
||||||
_namePlateGui.RequestRedraw();
|
_namePlateGui.RequestRedraw();
|
||||||
@@ -47,28 +54,24 @@ public class GuiHookService : DisposableMediatorSubscriberBase
|
|||||||
Mediator.Subscribe<DelayedFrameworkUpdateMessage>(this, (_) => GameSettingsCheck());
|
Mediator.Subscribe<DelayedFrameworkUpdateMessage>(this, (_) => GameSettingsCheck());
|
||||||
Mediator.Subscribe<PairHandlerVisibleMessage>(this, (_) => RequestRedraw());
|
Mediator.Subscribe<PairHandlerVisibleMessage>(this, (_) => RequestRedraw());
|
||||||
Mediator.Subscribe<NameplateRedrawMessage>(this, (_) => RequestRedraw());
|
Mediator.Subscribe<NameplateRedrawMessage>(this, (_) => RequestRedraw());
|
||||||
Mediator.Subscribe<UserTypingStateMessage>(this, (msg) =>
|
Mediator.Subscribe<UserTypingStateMessage>(this, (_) => RequestRedraw());
|
||||||
{
|
|
||||||
if (msg.Typing.IsTyping)
|
|
||||||
{
|
|
||||||
_typingUsers[msg.Typing.User.UID] = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_typingUsers.TryRemove(msg.Typing.User.UID, out _);
|
|
||||||
}
|
|
||||||
RequestRedraw();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RequestRedraw(bool force = false)
|
public void RequestRedraw(bool force = false)
|
||||||
{
|
{
|
||||||
if (!_configService.Current.UseNameColors)
|
var useColors = _configService.Current.UseNameColors;
|
||||||
|
var showTyping = _configService.Current.TypingIndicatorShowOnNameplates;
|
||||||
|
|
||||||
|
if (!useColors && !showTyping)
|
||||||
{
|
{
|
||||||
if (!_isModified && !force)
|
if (!_isModified && !force)
|
||||||
return;
|
return;
|
||||||
_isModified = false;
|
_isModified = false;
|
||||||
}
|
}
|
||||||
|
else if (!useColors)
|
||||||
|
{
|
||||||
|
_isModified = false;
|
||||||
|
}
|
||||||
|
|
||||||
_ = Task.Run(async () => {
|
_ = Task.Run(async () => {
|
||||||
await _dalamudUtil.RunOnFrameworkThread(() => _namePlateGui.RequestRedraw()).ConfigureAwait(false);
|
await _dalamudUtil.RunOnFrameworkThread(() => _namePlateGui.RequestRedraw()).ConfigureAwait(false);
|
||||||
@@ -87,10 +90,11 @@ public class GuiHookService : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
private void OnNamePlateUpdate(INamePlateUpdateContext context, IReadOnlyList<INamePlateUpdateHandler> handlers)
|
private void OnNamePlateUpdate(INamePlateUpdateContext context, IReadOnlyList<INamePlateUpdateHandler> handlers)
|
||||||
{
|
{
|
||||||
if (!_configService.Current.UseNameColors)
|
var applyColors = _configService.Current.UseNameColors;
|
||||||
|
var showTypingIndicator = _configService.Current.TypingIndicatorShowOnNameplates;
|
||||||
|
if (!applyColors && !showTypingIndicator)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var showTypingIndicator = _configService.Current.TypingIndicatorShowOnNameplates;
|
|
||||||
var visibleUsers = _pairManager.GetOnlineUserPairs().Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue);
|
var visibleUsers = _pairManager.GetOnlineUserPairs().Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue);
|
||||||
var visibleUsersIds = visibleUsers.Select(u => (ulong)u.PlayerCharacterId).ToHashSet();
|
var visibleUsersIds = visibleUsers.Select(u => (ulong)u.PlayerCharacterId).ToHashSet();
|
||||||
|
|
||||||
@@ -101,6 +105,11 @@ public class GuiHookService : DisposableMediatorSubscriberBase
|
|||||||
for (int i = 0; i < _partyList.Count; ++i)
|
for (int i = 0; i < _partyList.Count; ++i)
|
||||||
partyMembers[i] = _partyList[i]?.GameObject?.Address ?? nint.MaxValue;
|
partyMembers[i] = _partyList[i]?.GameObject?.Address ?? nint.MaxValue;
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var activeTypers = _typingStateService.GetActiveTypers(TypingDisplayTime);
|
||||||
|
var selfTypingActive = showTypingIndicator && _typingStateService.TryGetSelfTyping(TypingDisplayTime, out _, out _);
|
||||||
|
var localPlayerAddress = selfTypingActive ? _clientState.LocalPlayer?.Address ?? nint.Zero : nint.Zero;
|
||||||
|
|
||||||
foreach (var handler in handlers)
|
foreach (var handler in handlers)
|
||||||
{
|
{
|
||||||
if (handler != null && visibleUsersIds.Contains(handler.GameObjectId))
|
if (handler != null && visibleUsersIds.Contains(handler.GameObjectId))
|
||||||
@@ -108,24 +117,18 @@ public class GuiHookService : DisposableMediatorSubscriberBase
|
|||||||
if (_namePlateRoleColorsEnabled && partyMembers.Contains(handler.GameObject?.Address ?? nint.MaxValue))
|
if (_namePlateRoleColorsEnabled && partyMembers.Contains(handler.GameObject?.Address ?? nint.MaxValue))
|
||||||
continue;
|
continue;
|
||||||
var pair = visibleUsersDict[handler.GameObjectId];
|
var pair = visibleUsersDict[handler.GameObjectId];
|
||||||
|
if (applyColors)
|
||||||
|
{
|
||||||
var colors = !pair.IsApplicationBlocked ? _configService.Current.NameColors : _configService.Current.BlockedNameColors;
|
var colors = !pair.IsApplicationBlocked ? _configService.Current.NameColors : _configService.Current.BlockedNameColors;
|
||||||
handler.NameParts.TextWrap = (
|
handler.NameParts.TextWrap = (
|
||||||
BuildColorStartSeString(colors),
|
BuildColorStartSeString(colors),
|
||||||
BuildColorEndSeString(colors)
|
BuildColorEndSeString(colors)
|
||||||
);
|
);
|
||||||
_isModified = true;
|
_isModified = true;
|
||||||
if (showTypingIndicator
|
|
||||||
&& _typingUsers.TryGetValue(pair.UserData.UID, out var lastTyping)
|
|
||||||
&& (DateTime.UtcNow - lastTyping) < TypingDisplayTime)
|
|
||||||
{
|
|
||||||
var ssb = new SeStringBuilder();
|
|
||||||
ssb.Append(handler.Name);
|
|
||||||
ssb.Add(new IconPayload(BitmapFontIcon.AutoTranslateBegin));
|
|
||||||
ssb.AddText("...");
|
|
||||||
ssb.Add(new IconPayload(BitmapFontIcon.AutoTranslateEnd));
|
|
||||||
handler.Name = ssb.Build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ using MareSynchronos.MareConfiguration;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using MareSynchronos.PlayerData.Pairs;
|
using MareSynchronos.PlayerData.Pairs;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Dalamud.Game.Text;
|
using Dalamud.Game.Text;
|
||||||
using Dalamud.Game.Text.SeStringHandling;
|
using Dalamud.Game.Text.SeStringHandling;
|
||||||
@@ -20,56 +19,25 @@ public class PartyListTypingService : DisposableMediatorSubscriberBase
|
|||||||
private readonly IPartyList _partyList;
|
private readonly IPartyList _partyList;
|
||||||
private readonly MareConfigService _configService;
|
private readonly MareConfigService _configService;
|
||||||
private readonly PairManager _pairManager;
|
private readonly PairManager _pairManager;
|
||||||
private readonly ConcurrentDictionary<string, DateTime> _typingUsers = new();
|
private readonly TypingIndicatorStateService _typingStateService;
|
||||||
private readonly ConcurrentDictionary<string, DateTime> _typingNames = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
private static readonly TimeSpan TypingDisplayTime = TimeSpan.FromSeconds(2);
|
private static readonly TimeSpan TypingDisplayTime = TimeSpan.FromSeconds(2);
|
||||||
|
private static readonly TimeSpan TypingDisplayDelay = TimeSpan.FromMilliseconds(500);
|
||||||
|
private static readonly TimeSpan TypingDisplayFade = TypingDisplayTime;
|
||||||
|
|
||||||
public PartyListTypingService(ILogger<PartyListTypingService> logger,
|
public PartyListTypingService(ILogger<PartyListTypingService> logger,
|
||||||
MareMediator mediator,
|
MareMediator mediator,
|
||||||
IPartyList partyList,
|
IPartyList partyList,
|
||||||
PairManager pairManager,
|
PairManager pairManager,
|
||||||
MareConfigService configService)
|
MareConfigService configService,
|
||||||
|
TypingIndicatorStateService typingStateService)
|
||||||
: base(logger, mediator)
|
: base(logger, mediator)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_partyList = partyList;
|
_partyList = partyList;
|
||||||
_pairManager = pairManager;
|
_pairManager = pairManager;
|
||||||
_configService = configService;
|
_configService = configService;
|
||||||
|
_typingStateService = typingStateService;
|
||||||
|
|
||||||
Mediator.Subscribe<UserTypingStateMessage>(this, OnUserTyping);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnUserTyping(UserTypingStateMessage msg)
|
|
||||||
{
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var uid = msg.Typing.User.UID;
|
|
||||||
var aliasOrUid = msg.Typing.User.AliasOrUID ?? uid;
|
|
||||||
|
|
||||||
if (msg.Typing.IsTyping)
|
|
||||||
{
|
|
||||||
_typingUsers[uid] = now;
|
|
||||||
_typingNames[aliasOrUid] = now;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_typingUsers.TryRemove(uid, out _);
|
|
||||||
_typingNames.TryRemove(aliasOrUid, out _);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool HasTypingBubble(SeString name)
|
|
||||||
{
|
|
||||||
return name.Payloads.Any(p => p is IconPayload ip && ip.Icon == BitmapFontIcon.AutoTranslateBegin);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static SeString WithTypingBubble(SeString baseName)
|
|
||||||
{
|
|
||||||
var ssb = new SeStringBuilder();
|
|
||||||
ssb.Append(baseName);
|
|
||||||
ssb.Add(new IconPayload(BitmapFontIcon.AutoTranslateBegin));
|
|
||||||
ssb.AddText("...");
|
|
||||||
ssb.Add(new IconPayload(BitmapFontIcon.AutoTranslateEnd));
|
|
||||||
return ssb.Build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Draw()
|
public void Draw()
|
||||||
@@ -91,22 +59,20 @@ public class PartyListTypingService : DisposableMediatorSubscriberBase
|
|||||||
_logger.LogDebug(ex, "PartyListTypingService: failed to get visible users");
|
_logger.LogDebug(ex, "PartyListTypingService: failed to get visible users");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var activeTypers = _typingStateService.GetActiveTypers(TypingDisplayTime);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
foreach (var member in _partyList)
|
foreach (var member in _partyList)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(member.Name?.TextValue)) continue;
|
if (string.IsNullOrEmpty(member.Name?.TextValue)) continue;
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var displayName = member.Name.TextValue;
|
var displayName = member.Name.TextValue;
|
||||||
if (visibleByAlias.TryGetValue(displayName, out var uid)
|
if (visibleByAlias.TryGetValue(displayName, out var uid)
|
||||||
&& _typingUsers.TryGetValue(uid, out var last)
|
&& activeTypers.TryGetValue(uid, out var entry)
|
||||||
&& (now - last) < TypingDisplayTime)
|
&& (now - entry.LastUpdate) <= TypingDisplayFade)
|
||||||
{
|
{
|
||||||
if (!HasTypingBubble(member.Name))
|
|
||||||
{
|
|
||||||
// IPartyMember.Name is read-only; rendering bubble here requires Addon-level modification. Keeping compile-safe for now.
|
|
||||||
_logger.LogDebug("PartyListTypingService: bubble would be shown for {name}", displayName);
|
_logger.LogDebug("PartyListTypingService: bubble would be shown for {name}", displayName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
119
MareSynchronos/Services/TypingIndicatorStateService.cs
Normal file
119
MareSynchronos/Services/TypingIndicatorStateService.cs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Linq;
|
||||||
|
using MareSynchronos.Services.Mediator;
|
||||||
|
using MareSynchronos.WebAPI;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using MareSynchronos.API.Data;
|
||||||
|
|
||||||
|
namespace MareSynchronos.Services;
|
||||||
|
|
||||||
|
public sealed class TypingIndicatorStateService : IMediatorSubscriber, IDisposable
|
||||||
|
{
|
||||||
|
private sealed record TypingEntry(UserData User, DateTime FirstSeen, DateTime LastUpdate);
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, TypingEntry> _typingUsers = new(StringComparer.Ordinal);
|
||||||
|
private readonly ApiController _apiController;
|
||||||
|
private readonly ILogger<TypingIndicatorStateService> _logger;
|
||||||
|
private DateTime _selfTypingLast = DateTime.MinValue;
|
||||||
|
private DateTime _selfTypingStart = DateTime.MinValue;
|
||||||
|
private bool _selfTypingActive;
|
||||||
|
|
||||||
|
public TypingIndicatorStateService(ILogger<TypingIndicatorStateService> logger, MareMediator mediator, ApiController apiController)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_apiController = apiController;
|
||||||
|
Mediator = mediator;
|
||||||
|
|
||||||
|
mediator.Subscribe<UserTypingStateMessage>(this, OnTypingState);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Mediator.UnsubscribeAll(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MareMediator Mediator { get; }
|
||||||
|
|
||||||
|
public void SetSelfTypingLocal(bool isTyping)
|
||||||
|
{
|
||||||
|
if (isTyping)
|
||||||
|
{
|
||||||
|
if (!_selfTypingActive)
|
||||||
|
_selfTypingStart = DateTime.UtcNow;
|
||||||
|
_selfTypingLast = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_selfTypingStart = DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selfTypingActive = isTyping;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTypingState(UserTypingStateMessage msg)
|
||||||
|
{
|
||||||
|
var uid = msg.Typing.User.UID;
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (string.Equals(uid, _apiController.UID, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_selfTypingActive = msg.Typing.IsTyping;
|
||||||
|
if (_selfTypingActive)
|
||||||
|
{
|
||||||
|
if (_selfTypingStart == DateTime.MinValue)
|
||||||
|
_selfTypingStart = now;
|
||||||
|
_selfTypingLast = now;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_selfTypingStart = DateTime.MinValue;
|
||||||
|
}
|
||||||
|
_logger.LogInformation("Typing state self -> {state}", _selfTypingActive);
|
||||||
|
}
|
||||||
|
else if (msg.Typing.IsTyping)
|
||||||
|
{
|
||||||
|
_typingUsers.AddOrUpdate(uid,
|
||||||
|
_ => new TypingEntry(msg.Typing.User, now, now),
|
||||||
|
(_, existing) => new TypingEntry(msg.Typing.User, existing.FirstSeen, now));
|
||||||
|
_logger.LogInformation("Typing state {uid} -> true", uid);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_typingUsers.TryRemove(uid, out _);
|
||||||
|
_logger.LogInformation("Typing state {uid} -> false", uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetSelfTyping(TimeSpan maxAge, out DateTime startTyping, out DateTime lastTyping)
|
||||||
|
{
|
||||||
|
startTyping = _selfTypingStart;
|
||||||
|
lastTyping = _selfTypingLast;
|
||||||
|
if (!_selfTypingActive)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if ((now - _selfTypingLast) >= maxAge)
|
||||||
|
{
|
||||||
|
_selfTypingActive = false;
|
||||||
|
_selfTypingStart = DateTime.MinValue;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, (UserData User, DateTime FirstSeen, DateTime LastUpdate)> GetActiveTypers(TimeSpan maxAge)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
foreach (var kvp in _typingUsers.ToArray())
|
||||||
|
{
|
||||||
|
if ((now - kvp.Value.LastUpdate) >= maxAge)
|
||||||
|
{
|
||||||
|
_typingUsers.TryRemove(kvp.Key, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _typingUsers.ToDictionary(k => k.Key, v => (v.Value.User, v.Value.FirstSeen, v.Value.LastUpdate), StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,6 +169,10 @@ public sealed class ChangelogUi : WindowMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
return new List<ChangelogEntry>
|
return new List<ChangelogEntry>
|
||||||
{
|
{
|
||||||
|
new(new Version(0, 1, 9, 2), "0.1.9.2", new List<ChangelogLine>
|
||||||
|
{
|
||||||
|
new("Correctif de l'affichage de la bulle de frappe."),
|
||||||
|
}),
|
||||||
new(new Version(0, 1, 9, 1), "0.1.9.1", new List<ChangelogLine>
|
new(new Version(0, 1, 9, 1), "0.1.9.1", new List<ChangelogLine>
|
||||||
{
|
{
|
||||||
new("Début correctif pour la bulle de frappe."),
|
new("Début correctif pour la bulle de frappe."),
|
||||||
|
|||||||
422
MareSynchronos/UI/TypingIndicatorOverlay.cs
Normal file
422
MareSynchronos/UI/TypingIndicatorOverlay.cs
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
using Dalamud.Bindings.ImGui;
|
||||||
|
using Dalamud.Interface.Utility;
|
||||||
|
using Dalamud.Plugin.Services;
|
||||||
|
using MareSynchronos.MareConfiguration;
|
||||||
|
using MareSynchronos.PlayerData.Pairs;
|
||||||
|
using MareSynchronos.Services;
|
||||||
|
using MareSynchronos.Services.Mediator;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using MareSynchronos.WebAPI;
|
||||||
|
using MareSynchronos.API.Data;
|
||||||
|
using FFXIVClientStructs.Interop;
|
||||||
|
using Dalamud.Interface.Textures.TextureWraps;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.System.Framework;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||||
|
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||||
|
|
||||||
|
namespace MareSynchronos.UI;
|
||||||
|
|
||||||
|
public sealed class TypingIndicatorOverlay : WindowMediatorSubscriberBase
|
||||||
|
{
|
||||||
|
private const int NameplateIconId = 61397;
|
||||||
|
private static readonly TimeSpan TypingDisplayTime = TimeSpan.FromSeconds(2);
|
||||||
|
private static readonly TimeSpan TypingDisplayDelay = TimeSpan.FromMilliseconds(500);
|
||||||
|
private static readonly TimeSpan TypingDisplayFade = TypingDisplayTime;
|
||||||
|
|
||||||
|
private readonly ILogger<TypingIndicatorOverlay> _logger;
|
||||||
|
private readonly MareConfigService _configService;
|
||||||
|
private readonly IGameGui _gameGui;
|
||||||
|
private readonly ITextureProvider _textureProvider;
|
||||||
|
private readonly IClientState _clientState;
|
||||||
|
private readonly PairManager _pairManager;
|
||||||
|
private readonly IPartyList _partyList;
|
||||||
|
private readonly IObjectTable _objectTable;
|
||||||
|
private readonly DalamudUtilService _dalamudUtil;
|
||||||
|
private readonly TypingIndicatorStateService _typingStateService;
|
||||||
|
private readonly ApiController _apiController;
|
||||||
|
|
||||||
|
public TypingIndicatorOverlay(ILogger<TypingIndicatorOverlay> logger, MareMediator mediator, PerformanceCollectorService performanceCollectorService,
|
||||||
|
MareConfigService configService, IGameGui gameGui, ITextureProvider textureProvider, IClientState clientState,
|
||||||
|
IPartyList partyList, IObjectTable objectTable, DalamudUtilService dalamudUtil, PairManager pairManager,
|
||||||
|
TypingIndicatorStateService typingStateService, ApiController apiController)
|
||||||
|
: base(logger, mediator, nameof(TypingIndicatorOverlay), performanceCollectorService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_configService = configService;
|
||||||
|
_gameGui = gameGui;
|
||||||
|
_textureProvider = textureProvider;
|
||||||
|
_clientState = clientState;
|
||||||
|
_partyList = partyList;
|
||||||
|
_objectTable = objectTable;
|
||||||
|
_dalamudUtil = dalamudUtil;
|
||||||
|
_pairManager = pairManager;
|
||||||
|
_typingStateService = typingStateService;
|
||||||
|
_apiController = apiController;
|
||||||
|
|
||||||
|
RespectCloseHotkey = false;
|
||||||
|
IsOpen = true;
|
||||||
|
Flags |= ImGuiWindowFlags.NoDecoration | ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoFocusOnAppearing
|
||||||
|
| ImGuiWindowFlags.NoBackground | ImGuiWindowFlags.NoInputs | ImGuiWindowFlags.NoNav;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void DrawInternal()
|
||||||
|
{
|
||||||
|
var viewport = ImGui.GetMainViewport();
|
||||||
|
ImGuiHelpers.ForceNextWindowMainViewport();
|
||||||
|
ImGui.SetWindowPos(viewport.Pos);
|
||||||
|
ImGui.SetWindowSize(viewport.Size);
|
||||||
|
|
||||||
|
if (!_clientState.IsLoggedIn)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var showParty = _configService.Current.TypingIndicatorShowOnPartyList;
|
||||||
|
var showNameplates = _configService.Current.TypingIndicatorShowOnNameplates;
|
||||||
|
if (!showParty && !showNameplates)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var drawList = ImGui.GetWindowDrawList();
|
||||||
|
var activeTypers = _typingStateService.GetActiveTypers(TypingDisplayTime);
|
||||||
|
var hasSelf = _typingStateService.TryGetSelfTyping(TypingDisplayTime, out var selfStart, out var selfLast);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (showParty)
|
||||||
|
{
|
||||||
|
DrawPartyIndicators(drawList, activeTypers, hasSelf, now, selfStart, selfLast);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showNameplates)
|
||||||
|
{
|
||||||
|
DrawNameplateIndicators(drawList, activeTypers, hasSelf, now, selfStart, selfLast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsafe void DrawPartyIndicators(ImDrawListPtr drawList, IReadOnlyDictionary<string, (UserData User, DateTime FirstSeen, DateTime LastUpdate)> activeTypers,
|
||||||
|
bool selfActive, DateTime now, DateTime selfStart, DateTime selfLast)
|
||||||
|
{
|
||||||
|
var partyAddon = (AtkUnitBase*)_gameGui.GetAddonByName("_PartyList", 1).Address;
|
||||||
|
if (partyAddon == null || !partyAddon->IsVisible)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (selfActive
|
||||||
|
&& (now - selfStart) >= TypingDisplayDelay
|
||||||
|
&& (now - selfLast) <= TypingDisplayFade)
|
||||||
|
{
|
||||||
|
DrawPartyMemberTyping(drawList, partyAddon, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (uid, entry) in activeTypers)
|
||||||
|
{
|
||||||
|
if ((now - entry.LastUpdate) > TypingDisplayFade)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var pair = _pairManager.GetPairByUID(uid);
|
||||||
|
if (pair == null)
|
||||||
|
{
|
||||||
|
var alias = entry.User.AliasOrUID;
|
||||||
|
if (string.IsNullOrEmpty(alias))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var aliasIndex = GetPartyIndexForName(alias);
|
||||||
|
if (aliasIndex >= 0)
|
||||||
|
{
|
||||||
|
DrawPartyMemberTyping(drawList, partyAddon, aliasIndex);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var index = GetPartyIndexForObjectId(pair.PlayerCharacterId);
|
||||||
|
if (index < 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
DrawPartyMemberTyping(drawList, partyAddon, index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsafe void DrawPartyMemberTyping(ImDrawListPtr drawList, AtkUnitBase* partyList, int memberIndex)
|
||||||
|
{
|
||||||
|
if (memberIndex < 0 || memberIndex > 7) return;
|
||||||
|
|
||||||
|
var nodeIndex = 23 - memberIndex;
|
||||||
|
if (partyList->UldManager.NodeListCount <= nodeIndex) return;
|
||||||
|
|
||||||
|
var memberNode = (AtkComponentNode*)partyList->UldManager.NodeList[nodeIndex];
|
||||||
|
if (memberNode == null || !memberNode->AtkResNode.IsVisible()) return;
|
||||||
|
|
||||||
|
var iconNode = memberNode->Component->UldManager.NodeListCount > 4 ? memberNode->Component->UldManager.NodeList[4] : null;
|
||||||
|
if (iconNode == null) return;
|
||||||
|
|
||||||
|
var align = partyList->UldManager.NodeList[3]->Y;
|
||||||
|
var partyScale = partyList->Scale;
|
||||||
|
|
||||||
|
var iconOffset = new Vector2(-14, 8) * partyScale;
|
||||||
|
var iconSize = new Vector2(iconNode->Width / 2f, iconNode->Height / 2f) * partyScale;
|
||||||
|
|
||||||
|
var iconPos = new Vector2(
|
||||||
|
partyList->X + (memberNode->AtkResNode.X * partyScale) + (iconNode->X * partyScale) + (iconNode->Width * partyScale / 2f),
|
||||||
|
partyList->Y + align + (memberNode->AtkResNode.Y * partyScale) + (iconNode->Y * partyScale) + (iconNode->Height * partyScale / 2f));
|
||||||
|
|
||||||
|
iconPos += iconOffset;
|
||||||
|
|
||||||
|
var texture = _textureProvider.GetFromGame("ui/uld/charamake_dataimport.tex").GetWrapOrEmpty();
|
||||||
|
if (texture == null) return;
|
||||||
|
|
||||||
|
drawList.AddImage(texture.Handle, iconPos, iconPos + iconSize, Vector2.Zero, Vector2.One,
|
||||||
|
ImGui.ColorConvertFloat4ToU32(new Vector4(1f, 1f, 1f, 0.9f)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsafe void DrawNameplateIndicators(ImDrawListPtr drawList, IReadOnlyDictionary<string, (UserData User, DateTime FirstSeen, DateTime LastUpdate)> activeTypers,
|
||||||
|
bool selfActive, DateTime now, DateTime selfStart, DateTime selfLast)
|
||||||
|
{
|
||||||
|
var iconWrap = _textureProvider.GetFromGameIcon(NameplateIconId).GetWrapOrEmpty();
|
||||||
|
if (iconWrap == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (selfActive
|
||||||
|
&& _clientState.LocalPlayer != null
|
||||||
|
&& (now - selfStart) >= TypingDisplayDelay
|
||||||
|
&& (now - selfLast) <= TypingDisplayFade)
|
||||||
|
{
|
||||||
|
var selfId = GetEntityId(_clientState.LocalPlayer.Address);
|
||||||
|
if (selfId != 0)
|
||||||
|
{
|
||||||
|
if (!DrawNameplateIcon(drawList, iconWrap, selfId))
|
||||||
|
{
|
||||||
|
DrawWorldFallbackIcon(drawList, iconWrap, _clientState.LocalPlayer.Position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (uid, entry) in activeTypers)
|
||||||
|
{
|
||||||
|
if ((now - entry.LastUpdate) > TypingDisplayFade)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (string.Equals(uid, _apiController.UID, StringComparison.Ordinal))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var pair = _pairManager.GetPairByUID(uid);
|
||||||
|
var objectId = pair?.PlayerCharacterId ?? 0;
|
||||||
|
if (pair == null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("TypingIndicator: no pair found for {uid}, attempting fallback", uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
var drawnOnNameplate = objectId != uint.MaxValue && objectId != 0 && DrawNameplateIcon(drawList, iconWrap, objectId);
|
||||||
|
|
||||||
|
if (drawnOnNameplate)
|
||||||
|
{
|
||||||
|
_logger.LogTrace("TypingIndicator: drew nameplate icon for {uid} (objectId={objectId})", uid, objectId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pairName = pair?.PlayerName ?? entry.User.AliasOrUID ?? string.Empty;
|
||||||
|
var pairIdent = pair?.Ident ?? string.Empty;
|
||||||
|
|
||||||
|
_logger.LogInformation("TypingIndicator: fallback draw for {uid} (objectId={objectId}, name={name}, ident={ident})",
|
||||||
|
uid, objectId, pairName, pairIdent);
|
||||||
|
|
||||||
|
if (TryResolveWorldPosition(pair, entry.User, objectId, out var worldPos))
|
||||||
|
{
|
||||||
|
DrawWorldFallbackIcon(drawList, iconWrap, worldPos);
|
||||||
|
_logger.LogInformation("TypingIndicator: fallback world draw for {uid} at {pos}", uid, worldPos);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation("TypingIndicator: could not resolve position for {uid}", uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsafe bool DrawNameplateIcon(ImDrawListPtr drawList, IDalamudTextureWrap textureWrap, uint objectId)
|
||||||
|
{
|
||||||
|
var framework = Framework.Instance();
|
||||||
|
if (framework == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var ui3D = framework->GetUIModule()->GetUI3DModule();
|
||||||
|
if (ui3D == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
for (var i = 0; i < ui3D->NamePlateObjectInfoCount; i++)
|
||||||
|
{
|
||||||
|
var objectInfo = ui3D->NamePlateObjectInfoPointers[i];
|
||||||
|
if (objectInfo.Value == null || objectInfo.Value->GameObject == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (objectInfo.Value->GameObject->EntityId != objectId)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var addonNamePlate = (AddonNamePlate*)_gameGui.GetAddonByName("NamePlate", 1).Address;
|
||||||
|
if (addonNamePlate == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var npObject = &addonNamePlate->NamePlateObjectArray[objectInfo.Value->NamePlateIndex];
|
||||||
|
if (npObject == null || npObject->RootComponentNode == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var iconNode = npObject->RootComponentNode->Component->UldManager.NodeList[0];
|
||||||
|
if (iconNode == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var distance = objectInfo.Value->GameObject->YalmDistanceFromPlayerX;
|
||||||
|
var scaleX = npObject->RootComponentNode->AtkResNode.ScaleX;
|
||||||
|
var scaleY = npObject->RootComponentNode->AtkResNode.ScaleY;
|
||||||
|
var iconSize = new Vector2(40f * scaleX, 40f * scaleY);
|
||||||
|
|
||||||
|
var iconPos = new Vector2(
|
||||||
|
npObject->RootComponentNode->AtkResNode.X + iconNode->X + iconNode->Width,
|
||||||
|
npObject->RootComponentNode->AtkResNode.Y + iconNode->Y);
|
||||||
|
|
||||||
|
var iconOffset = new Vector2(distance / 1.5f, distance / 3f);
|
||||||
|
if (iconNode->Height == 24)
|
||||||
|
{
|
||||||
|
iconOffset.Y -= 8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
iconPos += iconOffset;
|
||||||
|
var extraScaleX = Math.Max(scaleX - 1f, 0f);
|
||||||
|
var extraScaleY = Math.Max(scaleY - 1f, 0f);
|
||||||
|
if (extraScaleX > 0f || extraScaleY > 0f)
|
||||||
|
{
|
||||||
|
iconPos -= new Vector2(extraScaleX * 14f, extraScaleY * 14f);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawList.AddImage(textureWrap.Handle, iconPos, iconPos + iconSize, Vector2.Zero, Vector2.One,
|
||||||
|
ImGui.ColorConvertFloat4ToU32(new Vector4(1f, 1f, 1f, 0.95f)));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawWorldFallbackIcon(ImDrawListPtr drawList, IDalamudTextureWrap textureWrap, Vector3 worldPosition)
|
||||||
|
{
|
||||||
|
var offsetPosition = worldPosition + new Vector3(0f, 1.8f, 0f);
|
||||||
|
if (!_gameGui.WorldToScreen(offsetPosition, out var screenPos))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var iconSize = new Vector2(36f, 36f) * ImGuiHelpers.GlobalScale;
|
||||||
|
var iconPos = screenPos - (iconSize / 2f) - new Vector2(0f, iconSize.Y * 0.6f);
|
||||||
|
drawList.AddImage(textureWrap.Handle, iconPos, iconPos + iconSize, Vector2.Zero, Vector2.One,
|
||||||
|
ImGui.ColorConvertFloat4ToU32(new Vector4(1f, 1f, 1f, 0.95f)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetWorldPosition(uint objectId, out Vector3 position)
|
||||||
|
{
|
||||||
|
position = Vector3.Zero;
|
||||||
|
if (objectId == 0 || objectId == uint.MaxValue)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
foreach (var obj in _objectTable)
|
||||||
|
{
|
||||||
|
if (obj?.EntityId == objectId)
|
||||||
|
{
|
||||||
|
position = obj.Position;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryResolveWorldPosition(Pair? pair, UserData userData, uint objectId, out Vector3 position)
|
||||||
|
{
|
||||||
|
if (TryGetWorldPosition(objectId, out position))
|
||||||
|
{
|
||||||
|
_logger.LogTrace("TypingIndicator: resolved by objectId {objectId}", objectId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pair != null)
|
||||||
|
{
|
||||||
|
var name = pair.PlayerName;
|
||||||
|
if (!string.IsNullOrEmpty(name) && TryGetWorldPositionByName(name!, out position))
|
||||||
|
{
|
||||||
|
_logger.LogTrace("TypingIndicator: resolved by pair name {name}", name);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ident = pair.Ident;
|
||||||
|
if (!string.IsNullOrEmpty(ident))
|
||||||
|
{
|
||||||
|
var cached = _dalamudUtil.FindPlayerByNameHash(ident);
|
||||||
|
if (!string.IsNullOrEmpty(cached.Name) && TryGetWorldPositionByName(cached.Name, out position))
|
||||||
|
{
|
||||||
|
_logger.LogTrace("TypingIndicator: resolved by cached name {name}", cached.Name);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cached.Address != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
var objRef = _objectTable.CreateObjectReference(cached.Address);
|
||||||
|
if (objRef != null)
|
||||||
|
{
|
||||||
|
position = objRef.Position;
|
||||||
|
_logger.LogTrace("TypingIndicator: resolved by cached address {addr}", cached.Address);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var alias = userData.AliasOrUID;
|
||||||
|
if (!string.IsNullOrEmpty(alias) && TryGetWorldPositionByName(alias, out position))
|
||||||
|
{
|
||||||
|
_logger.LogTrace("TypingIndicator: resolved by user alias {alias}", alias);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetWorldPositionByName(string name, out Vector3 position)
|
||||||
|
{
|
||||||
|
position = Vector3.Zero;
|
||||||
|
foreach (var obj in _objectTable)
|
||||||
|
{
|
||||||
|
if (obj != null && obj.Name.TextValue.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
position = obj.Position;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int GetPartyIndexForObjectId(uint objectId)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < _partyList.Count; ++i)
|
||||||
|
{
|
||||||
|
var member = _partyList[i];
|
||||||
|
if (member == null) continue;
|
||||||
|
|
||||||
|
var gameObject = member.GameObject;
|
||||||
|
if (gameObject != null && GetEntityId(gameObject.Address) == objectId)
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int GetPartyIndexForName(string name)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < _partyList.Count; ++i)
|
||||||
|
{
|
||||||
|
var member = _partyList[i];
|
||||||
|
if (member?.Name == null) continue;
|
||||||
|
|
||||||
|
if (member.Name.TextValue.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static unsafe uint GetEntityId(nint address)
|
||||||
|
{
|
||||||
|
if (address == nint.Zero) return 0;
|
||||||
|
return ((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)address)->EntityId;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
Program.cs
Normal file
15
Program.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using System;
|
||||||
|
using Mono.Cecil;
|
||||||
|
|
||||||
|
class Program
|
||||||
|
{
|
||||||
|
static void Main()
|
||||||
|
{
|
||||||
|
var asm = AssemblyDefinition.ReadAssembly("/Users/luca.genovese/Library/Application Support/XIV on Mac/dalamud/Hooks/dev/FFXIVClientStructs.dll");
|
||||||
|
var type = asm.MainModule.GetType("FFXIVClientStructs.FFXIV.Client.UI.AddonNamePlate/NamePlateObject");
|
||||||
|
foreach (var field in type.Fields)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"FIELD {field.Name}: {field.FieldType}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user