Update 0.1.9.2 - Fix BubleChat
This commit is contained in:
@@ -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<ChatService> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.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<string, DateTime> _typingUsers = new();
|
||||
private static readonly TimeSpan TypingDisplayTime = TimeSpan.FromSeconds(2);
|
||||
|
||||
private bool _isModified = false;
|
||||
private bool _namePlateRoleColorsEnabled = false;
|
||||
|
||||
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)
|
||||
{
|
||||
_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<DelayedFrameworkUpdateMessage>(this, (_) => GameSettingsCheck());
|
||||
Mediator.Subscribe<PairHandlerVisibleMessage>(this, (_) => RequestRedraw());
|
||||
Mediator.Subscribe<NameplateRedrawMessage>(this, (_) => RequestRedraw());
|
||||
Mediator.Subscribe<UserTypingStateMessage>(this, (msg) =>
|
||||
{
|
||||
if (msg.Typing.IsTyping)
|
||||
{
|
||||
_typingUsers[msg.Typing.User.UID] = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
_typingUsers.TryRemove(msg.Typing.User.UID, out _);
|
||||
}
|
||||
RequestRedraw();
|
||||
});
|
||||
Mediator.Subscribe<UserTypingStateMessage>(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<INamePlateUpdateHandler> 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, DateTime> _typingUsers = new();
|
||||
private readonly ConcurrentDictionary<string, DateTime> _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<PartyListTypingService> 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<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()
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user