From b59a579f565eb821a32e71276a0a3916ff1e919a Mon Sep 17 00:00:00 2001 From: Keda Date: Sat, 4 Oct 2025 19:15:27 +0200 Subject: [PATCH] Update 0.1.9.2 - Fix BubleChat --- Glamourer.Api | 2 +- MareAPI | 2 +- MareSynchronos/MarePlugin.cs | 2 +- MareSynchronos/MareSynchronos.csproj | 2 +- MareSynchronos/Plugin.cs | 3 + MareSynchronos/Services/ChatService.cs | 16 +- .../Services/ChatTypingDetectionService.cs | 294 ++++++++++++ MareSynchronos/Services/GuiHookService.cs | 71 +-- .../Services/PartyListTypingService.cs | 60 +-- .../Services/TypingIndicatorStateService.cs | 119 +++++ MareSynchronos/UI/ChangelogUi.cs | 4 + MareSynchronos/UI/TypingIndicatorOverlay.cs | 422 ++++++++++++++++++ Program.cs | 15 + 13 files changed, 923 insertions(+), 89 deletions(-) create mode 100644 MareSynchronos/Services/ChatTypingDetectionService.cs create mode 100644 MareSynchronos/Services/TypingIndicatorStateService.cs create mode 100644 MareSynchronos/UI/TypingIndicatorOverlay.cs create mode 100644 Program.cs diff --git a/Glamourer.Api b/Glamourer.Api index 54c1944..7e8505c 160000 --- a/Glamourer.Api +++ b/Glamourer.Api @@ -1 +1 @@ -Subproject commit 54c1944dc7db704733b4788520e494761bb0b58e +Subproject commit 7e8505cd6f8dbc5bcf41b72e16785d62b4d218f3 diff --git a/MareAPI b/MareAPI index 5fc7969..0abb078 160000 --- a/MareAPI +++ b/MareAPI @@ -1 +1 @@ -Subproject commit 5fc7969adfddf29566945c9f8d30f12cf5c9449c +Subproject commit 0abb078c211440d4823857d83dad87a90256049a diff --git a/MareSynchronos/MarePlugin.cs b/MareSynchronos/MarePlugin.cs index 3024cf2..c92bccb 100644 --- a/MareSynchronos/MarePlugin.cs +++ b/MareSynchronos/MarePlugin.cs @@ -153,7 +153,7 @@ public class MarePlugin : MediatorSubscriberBase, IHostedService _runtimeServiceScope.ServiceProvider.GetRequiredService(); _runtimeServiceScope.ServiceProvider.GetRequiredService(); _runtimeServiceScope.ServiceProvider.GetRequiredService(); - _runtimeServiceScope.ServiceProvider.GetRequiredService(); + _runtimeServiceScope.ServiceProvider.GetRequiredService(); _runtimeServiceScope.ServiceProvider.GetRequiredService(); #if !DEBUG diff --git a/MareSynchronos/MareSynchronos.csproj b/MareSynchronos/MareSynchronos.csproj index 9b969dd..1448877 100644 --- a/MareSynchronos/MareSynchronos.csproj +++ b/MareSynchronos/MareSynchronos.csproj @@ -3,7 +3,7 @@ UmbraSync UmbraSync - 0.1.9.1 + 0.1.9.2 diff --git a/MareSynchronos/Plugin.cs b/MareSynchronos/Plugin.cs index dfb7e95..e0da18b 100644 --- a/MareSynchronos/Plugin.cs +++ b/MareSynchronos/Plugin.cs @@ -148,6 +148,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); + collection.AddSingleton(); collection.AddSingleton((s) => new MareConfigService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton((s) => new ServerConfigService(pluginInterface.ConfigDirectory.FullName)); @@ -191,6 +192,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddScoped(); collection.AddScoped(); collection.AddScoped(); + collection.AddScoped(); collection.AddScoped(); collection.AddScoped(); collection.AddScoped(); @@ -202,6 +204,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddScoped(); collection.AddScoped(); collection.AddScoped(); + collection.AddScoped(); collection.AddHostedService(p => p.GetRequiredService()); collection.AddHostedService(p => p.GetRequiredService()); diff --git a/MareSynchronos/Services/ChatService.cs b/MareSynchronos/Services/ChatService.cs index 06a98cb..b52d61f 100644 --- a/MareSynchronos/Services/ChatService.cs +++ b/MareSynchronos/Services/ChatService.cs @@ -34,7 +34,9 @@ public class ChatService : DisposableMediatorSubscriberBase private readonly object _typingLock = new(); private CancellationTokenSource? _typingCts; private bool _isTypingAnnounced; + private DateTime _lastTypingSent = DateTime.MinValue; private static readonly TimeSpan TypingIdle = TimeSpan.FromSeconds(2); + private static readonly TimeSpan TypingResendInterval = TimeSpan.FromMilliseconds(750); public ChatService(ILogger logger, DalamudUtilService dalamudUtil, MareMediator mediator, ApiController apiController, PairManager pairManager, ILoggerFactory loggerFactory, IGameInteropProvider gameInteropProvider, IChatGui chatGui, @@ -78,7 +80,8 @@ public class ChatService : DisposableMediatorSubscriberBase { lock (_typingLock) { - if (!_isTypingAnnounced) + var now = DateTime.UtcNow; + if (!_isTypingAnnounced || (now - _lastTypingSent) >= TypingResendInterval) { _ = Task.Run(async () => { @@ -86,6 +89,7 @@ public class ChatService : DisposableMediatorSubscriberBase catch (Exception ex) { _logger.LogDebug(ex, "NotifyTypingKeystroke: failed to send typing=true"); } }); _isTypingAnnounced = true; + _lastTypingSent = now; } _typingCts?.Cancel(); @@ -113,7 +117,10 @@ public class ChatService : DisposableMediatorSubscriberBase lock (_typingLock) { if (!token.IsCancellationRequested) + { _isTypingAnnounced = false; + _lastTypingSent = DateTime.MinValue; + } } } }); @@ -133,10 +140,11 @@ public class ChatService : DisposableMediatorSubscriberBase try { await _apiController.UserSetTypingState(false).ConfigureAwait(false); } catch (Exception ex) { _logger.LogDebug(ex, "ClearTypingState: failed to send typing=false"); } }); - _isTypingAnnounced = false; - } + _isTypingAnnounced = false; + _lastTypingSent = DateTime.MinValue; } } + } private void HandleUserChat(UserChatMsgMessage message) { @@ -301,4 +309,4 @@ public class ChatService : DisposableMediatorSubscriberBase _chatGui.PrintError($"[UmbraSync] Syncshell number #{shellNumber} not found"); } -} \ No newline at end of file +} diff --git a/MareSynchronos/Services/ChatTypingDetectionService.cs b/MareSynchronos/Services/ChatTypingDetectionService.cs new file mode 100644 index 0000000..b8f6c2d --- /dev/null +++ b/MareSynchronos/Services/ChatTypingDetectionService.cs @@ -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 _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"); + } + } +} diff --git a/MareSynchronos/Services/GuiHookService.cs b/MareSynchronos/Services/GuiHookService.cs index d4d9467..dfa533f 100644 --- a/MareSynchronos/Services/GuiHookService.cs +++ b/MareSynchronos/Services/GuiHookService.cs @@ -1,14 +1,15 @@ +using System; using Dalamud.Game.Gui.NamePlate; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Plugin.Services; +using MareSynchronos.API.Dto.User; using MareSynchronos.MareConfiguration; using MareSynchronos.PlayerData.Pairs; using MareSynchronos.Services.Mediator; using MareSynchronos.UI; +using MareSynchronos.WebAPI; using Microsoft.Extensions.Logging; -using MareSynchronos.API.Dto.User; -using System.Collections.Concurrent; using Dalamud.Game.Text; namespace MareSynchronos.Services; @@ -22,15 +23,18 @@ public class GuiHookService : DisposableMediatorSubscriberBase private readonly IGameConfig _gameConfig; private readonly IPartyList _partyList; private readonly PairManager _pairManager; + private readonly IClientState _clientState; + private readonly ApiController _apiController; + private readonly TypingIndicatorStateService _typingStateService; - private readonly ConcurrentDictionary _typingUsers = new(); private static readonly TimeSpan TypingDisplayTime = TimeSpan.FromSeconds(2); private bool _isModified = false; private bool _namePlateRoleColorsEnabled = false; public GuiHookService(ILogger 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) { _logger = logger; @@ -40,6 +44,9 @@ public class GuiHookService : DisposableMediatorSubscriberBase _gameConfig = gameConfig; _partyList = partyList; _pairManager = pairManager; + _apiController = apiController; + _clientState = clientState; + _typingStateService = typingStateService; _namePlateGui.OnNamePlateUpdate += OnNamePlateUpdate; _namePlateGui.RequestRedraw(); @@ -47,28 +54,24 @@ public class GuiHookService : DisposableMediatorSubscriberBase Mediator.Subscribe(this, (_) => GameSettingsCheck()); Mediator.Subscribe(this, (_) => RequestRedraw()); Mediator.Subscribe(this, (_) => RequestRedraw()); - Mediator.Subscribe(this, (msg) => - { - if (msg.Typing.IsTyping) - { - _typingUsers[msg.Typing.User.UID] = DateTime.UtcNow; - } - else - { - _typingUsers.TryRemove(msg.Typing.User.UID, out _); - } - RequestRedraw(); - }); + Mediator.Subscribe(this, (_) => RequestRedraw()); } 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) return; _isModified = false; } + else if (!useColors) + { + _isModified = false; + } _ = Task.Run(async () => { await _dalamudUtil.RunOnFrameworkThread(() => _namePlateGui.RequestRedraw()).ConfigureAwait(false); @@ -87,10 +90,11 @@ public class GuiHookService : DisposableMediatorSubscriberBase private void OnNamePlateUpdate(INamePlateUpdateContext context, IReadOnlyList handlers) { - if (!_configService.Current.UseNameColors) + var applyColors = _configService.Current.UseNameColors; + var showTypingIndicator = _configService.Current.TypingIndicatorShowOnNameplates; + if (!applyColors && !showTypingIndicator) return; - var showTypingIndicator = _configService.Current.TypingIndicatorShowOnNameplates; var visibleUsers = _pairManager.GetOnlineUserPairs().Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue); 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) 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) { if (handler != null && visibleUsersIds.Contains(handler.GameObjectId)) @@ -108,24 +117,18 @@ public class GuiHookService : DisposableMediatorSubscriberBase if (_namePlateRoleColorsEnabled && partyMembers.Contains(handler.GameObject?.Address ?? nint.MaxValue)) continue; var pair = visibleUsersDict[handler.GameObjectId]; - var colors = !pair.IsApplicationBlocked ? _configService.Current.NameColors : _configService.Current.BlockedNameColors; - handler.NameParts.TextWrap = ( - BuildColorStartSeString(colors), - BuildColorEndSeString(colors) - ); - _isModified = true; - if (showTypingIndicator - && _typingUsers.TryGetValue(pair.UserData.UID, out var lastTyping) - && (DateTime.UtcNow - lastTyping) < TypingDisplayTime) + if (applyColors) { - 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(); + var colors = !pair.IsApplicationBlocked ? _configService.Current.NameColors : _configService.Current.BlockedNameColors; + handler.NameParts.TextWrap = ( + BuildColorStartSeString(colors), + BuildColorEndSeString(colors) + ); + _isModified = true; } + } + } } diff --git a/MareSynchronos/Services/PartyListTypingService.cs b/MareSynchronos/Services/PartyListTypingService.cs index 1eaaaf4..b894648 100644 --- a/MareSynchronos/Services/PartyListTypingService.cs +++ b/MareSynchronos/Services/PartyListTypingService.cs @@ -2,7 +2,6 @@ using MareSynchronos.MareConfiguration; using System.Collections.Generic; using MareSynchronos.PlayerData.Pairs; using System; -using System.Collections.Concurrent; using System.Linq; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; @@ -20,56 +19,25 @@ public class PartyListTypingService : DisposableMediatorSubscriberBase private readonly IPartyList _partyList; private readonly MareConfigService _configService; private readonly PairManager _pairManager; - private readonly ConcurrentDictionary _typingUsers = new(); - private readonly ConcurrentDictionary _typingNames = new(StringComparer.OrdinalIgnoreCase); + private readonly TypingIndicatorStateService _typingStateService; 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 logger, MareMediator mediator, IPartyList partyList, PairManager pairManager, - MareConfigService configService) + MareConfigService configService, + TypingIndicatorStateService typingStateService) : base(logger, mediator) { _logger = logger; _partyList = partyList; _pairManager = pairManager; _configService = configService; + _typingStateService = typingStateService; - Mediator.Subscribe(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() @@ -91,22 +59,20 @@ public class PartyListTypingService : DisposableMediatorSubscriberBase _logger.LogDebug(ex, "PartyListTypingService: failed to get visible users"); } + var activeTypers = _typingStateService.GetActiveTypers(TypingDisplayTime); + var now = DateTime.UtcNow; + foreach (var member in _partyList) { if (string.IsNullOrEmpty(member.Name?.TextValue)) continue; - var now = DateTime.UtcNow; var displayName = member.Name.TextValue; if (visibleByAlias.TryGetValue(displayName, out var uid) - && _typingUsers.TryGetValue(uid, out var last) - && (now - last) < TypingDisplayTime) + && activeTypers.TryGetValue(uid, out var entry) + && (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); } } } -} \ No newline at end of file +} diff --git a/MareSynchronos/Services/TypingIndicatorStateService.cs b/MareSynchronos/Services/TypingIndicatorStateService.cs new file mode 100644 index 0000000..87aae1a --- /dev/null +++ b/MareSynchronos/Services/TypingIndicatorStateService.cs @@ -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 _typingUsers = new(StringComparer.Ordinal); + private readonly ApiController _apiController; + private readonly ILogger _logger; + private DateTime _selfTypingLast = DateTime.MinValue; + private DateTime _selfTypingStart = DateTime.MinValue; + private bool _selfTypingActive; + + public TypingIndicatorStateService(ILogger logger, MareMediator mediator, ApiController apiController) + { + _logger = logger; + _apiController = apiController; + Mediator = mediator; + + mediator.Subscribe(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 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); + } +} diff --git a/MareSynchronos/UI/ChangelogUi.cs b/MareSynchronos/UI/ChangelogUi.cs index dc8c74c..ec908bb 100644 --- a/MareSynchronos/UI/ChangelogUi.cs +++ b/MareSynchronos/UI/ChangelogUi.cs @@ -169,6 +169,10 @@ public sealed class ChangelogUi : WindowMediatorSubscriberBase { return new List { + new(new Version(0, 1, 9, 2), "0.1.9.2", new List + { + new("Correctif de l'affichage de la bulle de frappe."), + }), new(new Version(0, 1, 9, 1), "0.1.9.1", new List { new("Début correctif pour la bulle de frappe."), diff --git a/MareSynchronos/UI/TypingIndicatorOverlay.cs b/MareSynchronos/UI/TypingIndicatorOverlay.cs new file mode 100644 index 0000000..0f7bcaa --- /dev/null +++ b/MareSynchronos/UI/TypingIndicatorOverlay.cs @@ -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 _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 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 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 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; + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..6a76759 --- /dev/null +++ b/Program.cs @@ -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}"); + } + } +}