using LightlessSync.LightlessConfiguration.Models; using LightlessSync.Services.Mediator; using LightlessSync.UI.Services; using Microsoft.Extensions.Logging; namespace LightlessSync.Services; public sealed class PairRequestService : DisposableMediatorSubscriberBase { private readonly DalamudUtilService _dalamudUtil; private readonly PairUiService _pairUiService; private readonly Lazy _apiController; private readonly Lock _syncRoot = new(); private readonly List _requests = []; private readonly Dictionary _displayNameCache = new(StringComparer.Ordinal); private static readonly TimeSpan _expiration = TimeSpan.FromMinutes(5); public PairRequestService( ILogger logger, LightlessMediator mediator, DalamudUtilService dalamudUtil, PairUiService pairUiService, Lazy apiController) : base(logger, mediator) { _dalamudUtil = dalamudUtil; _pairUiService = pairUiService; _apiController = apiController; Mediator.Subscribe(this, _ => { bool removed; lock (_syncRoot) { removed = CleanupExpiredUnsafe(); } if (removed) { Mediator.Publish(new PairRequestsUpdatedMessage()); } }); } public PairRequestDisplay RegisterIncomingRequest(string hashedCid, string messageTemplate) { if (string.IsNullOrWhiteSpace(hashedCid)) { hashedCid = string.Empty; } messageTemplate ??= string.Empty; PairRequestEntry entry = new(hashedCid, messageTemplate, DateTime.UtcNow); lock (_syncRoot) { CleanupExpiredUnsafe(); var index = _requests.FindIndex(r => string.Equals(r.HashedCid, hashedCid, StringComparison.Ordinal)); if (index >= 0) { _requests[index] = entry; } else { _requests.Add(entry); } } var display = _dalamudUtil.IsOnFrameworkThread ? ToDisplay(entry) : _dalamudUtil.RunOnFrameworkThread(() => ToDisplay(entry)).GetAwaiter().GetResult(); Mediator.Publish(new PairRequestsUpdatedMessage()); return display; } public IReadOnlyList GetActiveRequests() { List entries; lock (_syncRoot) { CleanupExpiredUnsafe(); entries = _requests .OrderByDescending(r => r.ReceivedAt) .ToList(); } return _dalamudUtil.IsOnFrameworkThread ? entries.Select(ToDisplay).ToList() : _dalamudUtil.RunOnFrameworkThread(() => entries.Select(ToDisplay).ToList()).GetAwaiter().GetResult(); } public bool RemoveRequest(string hashedCid) { bool removed; lock (_syncRoot) { removed = _requests.RemoveAll(r => string.Equals(r.HashedCid, hashedCid, StringComparison.Ordinal)) > 0; if (removed) { _displayNameCache.Remove(hashedCid); } } if (removed) { Mediator.Publish(new PairRequestsUpdatedMessage()); } return removed; } public bool HasPendingRequests() { lock (_syncRoot) { CleanupExpiredUnsafe(); return _requests.Count > 0; } } private PairRequestDisplay ToDisplay(PairRequestEntry entry) { var displayName = ResolveDisplayName(entry.HashedCid); var message = FormatMessage(entry.MessageTemplate, displayName); return new PairRequestDisplay(entry.HashedCid, displayName, message, entry.ReceivedAt); } private string ResolveDisplayName(string hashedCid) { if (string.IsNullOrWhiteSpace(hashedCid)) { return string.Empty; } if (TryGetCachedDisplayName(hashedCid, out var cached)) { return cached; } var resolved = ResolveDisplayNameInternal(hashedCid); if (!string.IsNullOrWhiteSpace(resolved)) { CacheDisplayName(hashedCid, resolved); return resolved; } return string.Empty; } private string ResolveDisplayNameInternal(string hashedCid) { var (name, address) = _dalamudUtil.FindPlayerByNameHash(hashedCid); if (!string.IsNullOrWhiteSpace(name)) { var worldName = _dalamudUtil.GetWorldNameFromPlayerAddress(address); return !string.IsNullOrWhiteSpace(worldName) ? $"{name} @ {worldName}" : name; } var snapshot = _pairUiService.GetSnapshot(); var pair = snapshot.PairsByUid.Values .Where(p => !string.IsNullOrEmpty(p.GetPlayerNameHash())) .FirstOrDefault(p => string.Equals(p.Ident, hashedCid, StringComparison.Ordinal)); if (pair != null) { if (!string.IsNullOrWhiteSpace(pair.PlayerName)) { return pair.PlayerName; } if (!string.IsNullOrWhiteSpace(pair.UserData.AliasOrUID)) { return pair.UserData.AliasOrUID; } } return string.Empty; } private static string FormatMessage(string template, string displayName) { var safeName = string.IsNullOrWhiteSpace(displayName) ? "Someone" : displayName; template ??= string.Empty; const string placeholder = "{DisplayName}"; if (!string.IsNullOrEmpty(template) && template.Contains(placeholder, StringComparison.Ordinal)) { return template.Replace(placeholder, safeName, StringComparison.Ordinal); } if (!string.IsNullOrWhiteSpace(template)) { return $"{safeName}: {template}"; } return $"{safeName} sent you a pair request."; } private bool CleanupExpiredUnsafe() { if (_requests.Count == 0) { return false; } var now = DateTime.UtcNow; var removedAny = false; for (var i = _requests.Count - 1; i >= 0; i--) { var entry = _requests[i]; if (now - entry.ReceivedAt <= _expiration) { continue; } _displayNameCache.Remove(entry.HashedCid); _requests.RemoveAt(i); removedAny = true; } return removedAny; } public void AcceptPairRequest(string hashedCid, string displayName) { _ = Task.Run(async () => { try { await _apiController.Value.TryPairWithContentId(hashedCid).ConfigureAwait(false); RemoveRequest(hashedCid); var displayText = string.IsNullOrEmpty(displayName) ? hashedCid : displayName; Mediator.Publish(new NotificationMessage( "Pair request accepted", $"Sent a pair request back to {displayText}.", NotificationType.Info, TimeSpan.FromSeconds(3))); } catch (Exception ex) { Logger.LogError(ex, "Failed to accept pair request for {HashedCid}", hashedCid); Mediator.Publish(new NotificationMessage( "Failed to Accept Pair Request", ex.Message, NotificationType.Error, TimeSpan.FromSeconds(5))); } }); } public void DeclinePairRequest(string hashedCid, string displayName) { RemoveRequest(hashedCid); Mediator.Publish(new NotificationMessage("Pair request declined", "Declined " + displayName + "'s pending pair request.", NotificationType.Info, TimeSpan.FromSeconds(3))); Logger.LogDebug("Declined pair request from {HashedCid}", hashedCid); } private record struct PairRequestEntry(string HashedCid, string MessageTemplate, DateTime ReceivedAt); public readonly record struct PairRequestDisplay(string HashedCid, string DisplayName, string Message, DateTime ReceivedAt); private bool TryGetCachedDisplayName(string hashedCid, out string displayName) { lock (_syncRoot) { if (!string.IsNullOrWhiteSpace(hashedCid) && _displayNameCache.TryGetValue(hashedCid, out var cached)) { displayName = cached; return true; } } displayName = string.Empty; return false; } private void CacheDisplayName(string hashedCid, string displayName) { if (string.IsNullOrWhiteSpace(hashedCid) || string.IsNullOrWhiteSpace(displayName) || string.Equals(hashedCid, displayName, StringComparison.Ordinal)) { return; } lock (_syncRoot) { _displayNameCache[hashedCid] = displayName; } } }