diff --git a/MareSynchronos/MareConfiguration/Configurations/CharaDataConfig.cs b/MareSynchronos/MareConfiguration/Configurations/CharaDataConfig.cs index e773b37..b92ac89 100644 --- a/MareSynchronos/MareConfiguration/Configurations/CharaDataConfig.cs +++ b/MareSynchronos/MareConfiguration/Configurations/CharaDataConfig.cs @@ -12,8 +12,9 @@ public class CharaDataConfig : IMareConfiguration public bool NearbyOwnServerOnly { get; set; } = false; public bool NearbyIgnoreHousingLimitations { get; set; } = false; public bool NearbyDrawWisps { get; set; } = true; + public int NearbyMaxWisps { get; set; } = 20; public int NearbyDistanceFilter { get; set; } = 100; public bool NearbyShowOwnData { get; set; } = false; public bool ShowHelpTexts { get; set; } = true; public bool NearbyShowAlways { get; set; } = false; -} \ No newline at end of file +} diff --git a/MareSynchronos/MareConfiguration/Configurations/MareConfig.cs b/MareSynchronos/MareConfiguration/Configurations/MareConfig.cs index 6fe3d6a..f8218cf 100644 --- a/MareSynchronos/MareConfiguration/Configurations/MareConfig.cs +++ b/MareSynchronos/MareConfiguration/Configurations/MareConfig.cs @@ -38,6 +38,7 @@ public class MareConfig : IMareConfiguration public bool OpenGposeImportOnGposeStart { get; set; } = false; public bool OpenPopupOnAdd { get; set; } = true; public int ParallelDownloads { get; set; } = 10; + public bool EnableDownloadQueue { get; set; } = false; public int DownloadSpeedLimitInBytes { get; set; } = 0; public DownloadSpeeds DownloadSpeedType { get; set; } = DownloadSpeeds.MBps; [Obsolete] public bool PreferNotesOverNamesForVisible { get; set; } = false; diff --git a/MareSynchronos/MareSynchronos.csproj b/MareSynchronos/MareSynchronos.csproj index bfaf3f1..d71724f 100644 --- a/MareSynchronos/MareSynchronos.csproj +++ b/MareSynchronos/MareSynchronos.csproj @@ -3,7 +3,7 @@ UmbraSync UmbraSync - 0.1.9.4 + 0.1.9.5 diff --git a/MareSynchronos/PlayerData/Factories/FileDownloadManagerFactory.cs b/MareSynchronos/PlayerData/Factories/FileDownloadManagerFactory.cs index f208ee9..003ab5d 100644 --- a/MareSynchronos/PlayerData/Factories/FileDownloadManagerFactory.cs +++ b/MareSynchronos/PlayerData/Factories/FileDownloadManagerFactory.cs @@ -1,4 +1,5 @@ using MareSynchronos.FileCache; +using MareSynchronos.MareConfiguration; using MareSynchronos.Services.Mediator; using MareSynchronos.WebAPI.Files; using Microsoft.Extensions.Logging; @@ -10,21 +11,23 @@ public class FileDownloadManagerFactory private readonly FileCacheManager _fileCacheManager; private readonly FileCompactor _fileCompactor; private readonly FileTransferOrchestrator _fileTransferOrchestrator; + private readonly MareConfigService _mareConfigService; private readonly ILoggerFactory _loggerFactory; private readonly MareMediator _mareMediator; public FileDownloadManagerFactory(ILoggerFactory loggerFactory, MareMediator mareMediator, FileTransferOrchestrator fileTransferOrchestrator, - FileCacheManager fileCacheManager, FileCompactor fileCompactor) + FileCacheManager fileCacheManager, FileCompactor fileCompactor, MareConfigService mareConfigService) { _loggerFactory = loggerFactory; _mareMediator = mareMediator; _fileTransferOrchestrator = fileTransferOrchestrator; _fileCacheManager = fileCacheManager; _fileCompactor = fileCompactor; + _mareConfigService = mareConfigService; } public FileDownloadManager Create() { - return new FileDownloadManager(_loggerFactory.CreateLogger(), _mareMediator, _fileTransferOrchestrator, _fileCacheManager, _fileCompactor); + return new FileDownloadManager(_loggerFactory.CreateLogger(), _mareMediator, _fileTransferOrchestrator, _fileCacheManager, _fileCompactor, _mareConfigService); } -} \ No newline at end of file +} diff --git a/MareSynchronos/Services/AutoDetect/AutoDetectRequestService.cs b/MareSynchronos/Services/AutoDetect/AutoDetectRequestService.cs index a621362..b83544c 100644 --- a/MareSynchronos/Services/AutoDetect/AutoDetectRequestService.cs +++ b/MareSynchronos/Services/AutoDetect/AutoDetectRequestService.cs @@ -9,6 +9,9 @@ using MareSynchronos.MareConfiguration; using MareSynchronos.Services; using MareSynchronos.Services.Mediator; using NotificationType = MareSynchronos.MareConfiguration.Models.NotificationType; +using MareSynchronos.WebAPI; +using MareSynchronos.API.Dto.User; +using MareSynchronos.API.Data; namespace MareSynchronos.Services.AutoDetect; @@ -26,8 +29,9 @@ public class AutoDetectRequestService private readonly ConcurrentDictionary _pendingRequests = new(StringComparer.Ordinal); private static readonly TimeSpan RequestCooldown = TimeSpan.FromMinutes(5); private static readonly TimeSpan RefusalLockDuration = TimeSpan.FromMinutes(15); + private readonly ApiController _apiController; - public AutoDetectRequestService(ILogger logger, DiscoveryConfigProvider configProvider, DiscoveryApiClient client, MareConfigService configService, MareMediator mediator, DalamudUtilService dalamudUtilService) + public AutoDetectRequestService(ILogger logger, DiscoveryConfigProvider configProvider, DiscoveryApiClient client, MareConfigService configService, MareMediator mediator, DalamudUtilService dalamudUtilService, ApiController apiController) { _logger = logger; _configProvider = configProvider; @@ -35,9 +39,10 @@ public class AutoDetectRequestService _configService = configService; _mediator = mediator; _dalamud = dalamudUtilService; + _apiController = apiController; } - public async Task SendRequestAsync(string token, string? uid = null, string? targetDisplayName = null, CancellationToken ct = default) + public async Task SendRequestAsync(string? token, string? uid = null, string? targetDisplayName = null, CancellationToken ct = default) { if (!_configService.Current.AllowAutoDetectPairRequests) { @@ -45,6 +50,13 @@ public class AutoDetectRequestService _mediator.Publish(new NotificationMessage("Nearby request blocked", "Enable 'Allow pair requests' in Settings to send requests.", NotificationType.Info)); return false; } + + if (string.IsNullOrEmpty(token) && string.IsNullOrEmpty(uid)) + { + _logger.LogDebug("Nearby request blocked: no token or UID provided"); + return false; + } + var targetKey = BuildTargetKey(uid, token, targetDisplayName); if (!string.IsNullOrEmpty(targetKey)) { @@ -101,8 +113,11 @@ public class AutoDetectRequestService } catch { } + var requestToken = string.IsNullOrEmpty(token) ? null : token; + var requestUid = requestToken == null ? uid : null; + _logger.LogInformation("Nearby: sending pair request via {endpoint}", endpoint); - var ok = await _client.SendRequestAsync(endpoint!, token, displayName, ct).ConfigureAwait(false); + var ok = await _client.SendRequestAsync(endpoint!, requestToken, requestUid, displayName, ct).ConfigureAwait(false); if (ok) { if (!string.IsNullOrEmpty(targetKey)) @@ -180,6 +195,126 @@ public class AutoDetectRequestService return await _client.SendAcceptAsync(endpoint!, targetUid, displayName, ct).ConfigureAwait(false); } + public async Task SendDirectUidRequestAsync(string uid, string? targetDisplayName = null, CancellationToken ct = default) + { + if (!_configService.Current.AllowAutoDetectPairRequests) + { + _logger.LogDebug("Nearby request blocked: AllowAutoDetectPairRequests is disabled"); + _mediator.Publish(new NotificationMessage("Nearby request blocked", "Enable 'Allow pair requests' in Settings to send requests.", NotificationType.Info)); + return false; + } + + if (string.IsNullOrEmpty(uid)) + { + _logger.LogDebug("Direct pair request aborted: UID is empty"); + return false; + } + + var targetKey = BuildTargetKey(uid, null, targetDisplayName); + if (!string.IsNullOrEmpty(targetKey)) + { + var now = DateTime.UtcNow; + lock (_syncRoot) + { + if (_refusalTrackers.TryGetValue(targetKey, out var tracker)) + { + if (tracker.LockUntil.HasValue && tracker.LockUntil.Value > now) + { + PublishLockNotification(tracker.LockUntil.Value - now); + return false; + } + + if (tracker.LockUntil.HasValue && tracker.LockUntil.Value <= now) + { + tracker.LockUntil = null; + tracker.Count = 0; + if (tracker.Count == 0 && tracker.LockUntil == null) + { + _refusalTrackers.Remove(targetKey); + } + } + } + + if (_activeCooldowns.TryGetValue(targetKey, out var lastSent)) + { + var elapsed = now - lastSent; + if (elapsed < RequestCooldown) + { + PublishCooldownNotification(RequestCooldown - elapsed); + return false; + } + + if (elapsed >= RequestCooldown) + { + _activeCooldowns.Remove(targetKey); + } + } + } + } + + try + { + await _apiController.UserAddPair(new UserDto(new UserData(uid))).ConfigureAwait(false); + + if (!string.IsNullOrEmpty(targetKey)) + { + lock (_syncRoot) + { + _activeCooldowns[targetKey] = DateTime.UtcNow; + if (_refusalTrackers.TryGetValue(targetKey, out var tracker)) + { + tracker.Count = 0; + tracker.LockUntil = null; + if (tracker.Count == 0 && tracker.LockUntil == null) + { + _refusalTrackers.Remove(targetKey); + } + } + } + } + + _mediator.Publish(new NotificationMessage("Nearby request sent", "The other user will receive a request notification.", NotificationType.Info)); + var pendingKey = EnsureTargetKey(targetKey); + var label = !string.IsNullOrWhiteSpace(targetDisplayName) ? targetDisplayName! : uid; + _pendingRequests[pendingKey] = new PendingRequestInfo(pendingKey, uid, null, label, DateTime.UtcNow); + + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Direct pair request failed for {uid}", uid); + if (!string.IsNullOrEmpty(targetKey)) + { + var now = DateTime.UtcNow; + lock (_syncRoot) + { + _activeCooldowns.Remove(targetKey); + if (!_refusalTrackers.TryGetValue(targetKey, out var tracker)) + { + tracker = new RefusalTracker(); + _refusalTrackers[targetKey] = tracker; + } + + if (tracker.LockUntil.HasValue && tracker.LockUntil.Value <= now) + { + tracker.LockUntil = null; + tracker.Count = 0; + } + + tracker.Count++; + if (tracker.Count >= 3) + { + tracker.LockUntil = now.Add(RefusalLockDuration); + } + } + _pendingRequests.TryRemove(targetKey, out _); + } + + _mediator.Publish(new NotificationMessage("Nearby request failed", "The server rejected the request. Try again soon.", NotificationType.Warning)); + return false; + } + } + private static string? BuildTargetKey(string? uid, string? token, string? displayName) { if (!string.IsNullOrEmpty(uid)) return "uid:" + uid; diff --git a/MareSynchronos/Services/AutoDetect/NearbyPendingService.cs b/MareSynchronos/Services/AutoDetect/NearbyPendingService.cs index 8ff5e8c..28a079f 100644 --- a/MareSynchronos/Services/AutoDetect/NearbyPendingService.cs +++ b/MareSynchronos/Services/AutoDetect/NearbyPendingService.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Concurrent; using System.Text.RegularExpressions; +using MareSynchronos.MareConfiguration.Models; using MareSynchronos.Services.Mediator; using MareSynchronos.WebAPI; using Microsoft.Extensions.Logging; @@ -23,6 +25,7 @@ public sealed class NearbyPendingService : IMediatorSubscriber _api = api; _requestService = requestService; _mediator.Subscribe(this, OnNotification); + _mediator.Subscribe(this, OnManualPairInvite); } public MareMediator Mediator => _mediator; @@ -65,6 +68,20 @@ public sealed class NearbyPendingService : IMediatorSubscriber _logger.LogInformation("NearbyPending: received request from {uid} ({name})", uid, name); } + private void OnManualPairInvite(ManualPairInviteMessage msg) + { + if (!string.Equals(msg.TargetUid, _api.UID, StringComparison.Ordinal)) + return; + + var display = !string.IsNullOrWhiteSpace(msg.DisplayName) + ? msg.DisplayName! + : (!string.IsNullOrWhiteSpace(msg.SourceAlias) ? msg.SourceAlias : msg.SourceUid); + + _pending[msg.SourceUid] = display; + _logger.LogInformation("NearbyPending: received manual invite from {uid} ({name})", msg.SourceUid, display); + _mediator.Publish(new NotificationMessage("Nearby request", $"{display} vous a envoyé une invitation de pair.", NotificationType.Info, TimeSpan.FromSeconds(5))); + } + public void Remove(string uid) { _pending.TryRemove(uid, out _); diff --git a/MareSynchronos/Services/CharaData/CharaDataNearbyManager.cs b/MareSynchronos/Services/CharaData/CharaDataNearbyManager.cs index 21eb91c..a7ed9fb 100644 --- a/MareSynchronos/Services/CharaData/CharaDataNearbyManager.cs +++ b/MareSynchronos/Services/CharaData/CharaDataNearbyManager.cs @@ -266,26 +266,47 @@ public sealed class CharaDataNearbyManager : DisposableMediatorSubscriberBase private void ManageWispsNearby(List previousPoses) { - foreach (var data in _nearbyData.Keys) + int maxWisps = _charaDataConfigService.Current.NearbyMaxWisps; + if (maxWisps <= 0) { - if (_poseVfx.TryGetValue(data, out var _)) continue; + ClearAllVfx(); + return; + } + + const int hardLimit = 200; + if (maxWisps > hardLimit) maxWisps = hardLimit; + + var orderedAllowedPoses = _nearbyData + .OrderBy(kvp => kvp.Value.Distance) + .Take(maxWisps) + .Select(kvp => kvp.Key) + .ToList(); + + var allowedPoseSet = orderedAllowedPoses.ToHashSet(); + + foreach (var data in orderedAllowedPoses) + { + if (_poseVfx.TryGetValue(data, out _)) continue; + + Guid? vfxGuid = data.MetaInfo.IsOwnData + ? _vfxSpawnManager.SpawnObject(data.Position, data.Rotation, Vector3.One * 2, 0.8f, 0.5f, 0.0f, 0.7f) + : _vfxSpawnManager.SpawnObject(data.Position, data.Rotation, Vector3.One * 2); - Guid? vfxGuid; - if (data.MetaInfo.IsOwnData) - { - vfxGuid = _vfxSpawnManager.SpawnObject(data.Position, data.Rotation, Vector3.One * 2, 0.8f, 0.5f, 0.0f, 0.7f); - } - else - { - vfxGuid = _vfxSpawnManager.SpawnObject(data.Position, data.Rotation, Vector3.One * 2); - } if (vfxGuid != null) { _poseVfx[data] = vfxGuid.Value; } } - foreach (var data in previousPoses.Except(_nearbyData.Keys)) + foreach (var data in previousPoses.Except(allowedPoseSet)) + { + if (_poseVfx.Remove(data, out var guid)) + { + _vfxSpawnManager.DespawnObject(guid); + } + } + + foreach (var data in _poseVfx.Keys.Except(allowedPoseSet).ToList()) { if (_poseVfx.Remove(data, out var guid)) { diff --git a/MareSynchronos/Services/ChatService.cs b/MareSynchronos/Services/ChatService.cs index b52d61f..f893903 100644 --- a/MareSynchronos/Services/ChatService.cs +++ b/MareSynchronos/Services/ChatService.cs @@ -1,3 +1,5 @@ +using System; +using System.Text; using System.Threading; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; @@ -19,6 +21,7 @@ namespace MareSynchronos.Services; public class ChatService : DisposableMediatorSubscriberBase { public const int DefaultColor = 710; + private const string ManualPairInvitePrefix = "[UmbraPairInvite|"; public const int CommandMaxNumber = 50; private readonly ILogger _logger; @@ -191,6 +194,10 @@ public class ChatService : DisposableMediatorSubscriberBase var extraChatTags = _mareConfig.Current.ExtraChatTags; var logKind = ResolveShellLogKind(shellConfig.LogKind); + var payload = SeString.Parse(message.ChatMsg.PayloadContent); + if (TryHandleManualPairInvite(message, payload)) + return; + var msg = new SeStringBuilder(); if (extraChatTags) { @@ -209,7 +216,7 @@ public class ChatService : DisposableMediatorSubscriberBase msg.Add(new PlayerPayload(chatMsg.SenderName, chatMsg.SenderHomeWorldId)); } msg.AddText("> "); - msg.Append(SeString.Parse(message.ChatMsg.PayloadContent)); + msg.Append(payload); if (color != 0) msg.AddUiForegroundOff(); @@ -219,6 +226,52 @@ public class ChatService : DisposableMediatorSubscriberBase Type = logKind }); } + + private bool TryHandleManualPairInvite(GroupChatMsgMessage message, SeString payload) + { + var textValue = payload.TextValue; + if (string.IsNullOrEmpty(textValue) || !textValue.StartsWith(ManualPairInvitePrefix, StringComparison.Ordinal)) + return false; + + var content = textValue[ManualPairInvitePrefix.Length..]; + if (content.EndsWith("]", StringComparison.Ordinal)) + { + content = content[..^1]; + } + + var parts = content.Split('|'); + if (parts.Length < 4) + return true; + + var sourceUid = parts[0]; + var sourceAlias = DecodeInviteField(parts[1]); + var targetUid = parts[2]; + var displayName = DecodeInviteField(parts[3]); + var inviteId = parts.Length > 4 ? parts[4] : Guid.NewGuid().ToString("N"); + + if (!string.Equals(targetUid, _apiController.UID, StringComparison.Ordinal)) + return true; + + Mediator.Publish(new ManualPairInviteMessage(sourceUid, sourceAlias, targetUid, string.IsNullOrEmpty(displayName) ? null : displayName, inviteId)); + _logger.LogDebug("Received manual pair invite from {source} via syncshell", sourceUid); + + return true; + } + + private static string DecodeInviteField(string encoded) + { + if (string.IsNullOrEmpty(encoded)) return string.Empty; + + try + { + var bytes = Convert.FromBase64String(encoded); + return Encoding.UTF8.GetString(bytes); + } + catch + { + return encoded; + } + } public void PrintChannelExample(string message, string gid = "") { int chatType = _mareConfig.Current.ChatLogKind; diff --git a/MareSynchronos/Services/Mediator/Messages.cs b/MareSynchronos/Services/Mediator/Messages.cs index cb68774..09e01fb 100644 --- a/MareSynchronos/Services/Mediator/Messages.cs +++ b/MareSynchronos/Services/Mediator/Messages.cs @@ -115,6 +115,7 @@ public record NearbyEntry(string Name, ushort WorldId, float Distance, bool IsMa public record DiscoveryListUpdated(List Entries) : MessageBase; public record NearbyDetectionToggled(bool Enabled) : MessageBase; public record AllowPairRequestsToggled(bool Enabled) : MessageBase; +public record ManualPairInviteMessage(string SourceUid, string SourceAlias, string TargetUid, string? DisplayName, string InviteId) : MessageBase; public record ApplyDefaultPairPermissionsMessage(UserPairDto Pair) : MessageBase; public record ApplyDefaultGroupPermissionsMessage(GroupPairFullInfoDto GroupPair) : MessageBase; public record ApplyDefaultsToAllSyncsMessage(string? Context = null, bool? Disabled = null) : MessageBase; diff --git a/MareSynchronos/Services/TypingIndicatorStateService.cs b/MareSynchronos/Services/TypingIndicatorStateService.cs index 87aae1a..9e4ce2c 100644 --- a/MareSynchronos/Services/TypingIndicatorStateService.cs +++ b/MareSynchronos/Services/TypingIndicatorStateService.cs @@ -76,12 +76,10 @@ public sealed class TypingIndicatorStateService : IMediatorSubscriber, IDisposab _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); } } diff --git a/MareSynchronos/UI/ChangelogUi.cs b/MareSynchronos/UI/ChangelogUi.cs index c123fad..d111f94 100644 --- a/MareSynchronos/UI/ChangelogUi.cs +++ b/MareSynchronos/UI/ChangelogUi.cs @@ -169,6 +169,14 @@ public sealed class ChangelogUi : WindowMediatorSubscriberBase { return new List { + new(new Version(0, 1, 9, 5), "0.1.9.5", new List + { + new("Fix l'affichage de la bulle dans la liste du groupe."), + new("Amélioration de l'ajout des utilisateurs via le bouton +."), + new("Possibilité de mettre en pause individuellement des utilisateurs d'une syncshell."), + new("Amélioration de la stabilité du plugin en cas de petite connexion / petite configuration."), + new("Divers fix de l'interface."), + }), new(new Version(0, 1, 9, 4), "0.1.9.4", new List { new("Réécriture complète de la bulle de frappe avec la possibilité de choisir la taille de la bulle."), diff --git a/MareSynchronos/UI/CharaDataHubUi.NearbyPoses.cs b/MareSynchronos/UI/CharaDataHubUi.NearbyPoses.cs index 8486375..cb183e5 100644 --- a/MareSynchronos/UI/CharaDataHubUi.NearbyPoses.cs +++ b/MareSynchronos/UI/CharaDataHubUi.NearbyPoses.cs @@ -57,6 +57,14 @@ internal partial class CharaDataHubUi _configService.Save(); } _uiSharedService.DrawHelpText("Draw floating wisps where other's poses are in the world."); + int maxWisps = _configService.Current.NearbyMaxWisps; + ImGui.SetNextItemWidth(140); + if (ImGui.SliderInt("Maximum wisps", ref maxWisps, 0, 200)) + { + _configService.Current.NearbyMaxWisps = maxWisps; + _configService.Save(); + } + _uiSharedService.DrawHelpText("Limit how many wisps are active at once. Set to 0 to disable wisps even when enabled above."); int poseDetectionDistance = _configService.Current.NearbyDistanceFilter; ImGui.SetNextItemWidth(100); if (ImGui.SliderInt("Detection Distance", ref poseDetectionDistance, 5, 1000)) diff --git a/MareSynchronos/UI/CompactUI.cs b/MareSynchronos/UI/CompactUI.cs index df97403..c2e59e2 100644 --- a/MareSynchronos/UI/CompactUI.cs +++ b/MareSynchronos/UI/CompactUI.cs @@ -88,7 +88,7 @@ public class CompactUi : WindowMediatorSubscriberBase _characterAnalyzer = characterAnalyzer; var tagHandler = new TagHandler(_serverManager); - _groupPanel = new(this, uiShared, _pairManager, chatService, uidDisplayHandler, _configService, _serverManager, _charaDataManager); + _groupPanel = new(this, uiShared, _pairManager, chatService, uidDisplayHandler, _configService, _serverManager, _charaDataManager, _autoDetectRequestService); _selectGroupForPairUi = new(tagHandler, uidDisplayHandler, _uiSharedService); _selectPairsForGroupUi = new(tagHandler, uidDisplayHandler); _pairGroupsUi = new(configService, tagHandler, uidDisplayHandler, apiController, _selectPairsForGroupUi, _uiSharedService); @@ -433,6 +433,8 @@ public class CompactUi : WindowMediatorSubscriberBase var compressedValue = UiSharedService.ByteToString(summary.TotalCompressedSize); Vector4? compressedColor = null; + FontAwesomeIcon? compressedIcon = null; + Vector4? compressedIconColor = null; string? compressedTooltip = null; if (summary.HasUncomputedEntries) { @@ -443,19 +445,25 @@ public class CompactUi : WindowMediatorSubscriberBase { compressedColor = ImGuiColors.DalamudYellow; compressedTooltip = "Au-delà de 300 MiB, certains joueurs peuvent ne pas voir toutes vos modifications."; + compressedIcon = FontAwesomeIcon.ExclamationTriangle; + compressedIconColor = ImGuiColors.DalamudYellow; } - DrawSelfAnalysisStatRow("Taille compressée", compressedValue, compressedColor, compressedTooltip); + DrawSelfAnalysisStatRow("Taille compressée", compressedValue, compressedColor, compressedTooltip, compressedIcon, compressedIconColor); DrawSelfAnalysisStatRow("Taille extraite", UiSharedService.ByteToString(summary.TotalOriginalSize)); Vector4? trianglesColor = null; + FontAwesomeIcon? trianglesIcon = null; + Vector4? trianglesIconColor = null; string? trianglesTooltip = null; if (summary.TotalTriangles >= SelfAnalysisTriangleWarningThreshold) { trianglesColor = ImGuiColors.DalamudYellow; trianglesTooltip = "Plus de 150k triangles peuvent entraîner un auto-pause et impacter les performances."; + trianglesIcon = FontAwesomeIcon.ExclamationTriangle; + trianglesIconColor = ImGuiColors.DalamudYellow; } - DrawSelfAnalysisStatRow("Triangles moddés", UiSharedService.TrisToString(summary.TotalTriangles), trianglesColor, trianglesTooltip); + DrawSelfAnalysisStatRow("Triangles moddés", UiSharedService.TrisToString(summary.TotalTriangles), trianglesColor, trianglesTooltip, trianglesIcon, trianglesIconColor); ImGui.EndTable(); } @@ -490,12 +498,29 @@ public class CompactUi : WindowMediatorSubscriberBase } } - private static void DrawSelfAnalysisStatRow(string label, string value, Vector4? valueColor = null, string? tooltip = null) + private static void DrawSelfAnalysisStatRow(string label, string value, Vector4? valueColor = null, string? tooltip = null, FontAwesomeIcon? icon = null, Vector4? iconColor = null) { ImGui.TableNextRow(); ImGui.TableNextColumn(); ImGui.TextUnformatted(label); ImGui.TableNextColumn(); + if (icon.HasValue) + { + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + if (iconColor.HasValue) + { + using var iconColorPush = ImRaii.PushColor(ImGuiCol.Text, iconColor.Value); + ImGui.TextUnformatted(icon.Value.ToIconString()); + } + else + { + ImGui.TextUnformatted(icon.Value.ToIconString()); + } + } + ImGui.SameLine(0f, 4f); + } + if (valueColor.HasValue) { using var color = ImRaii.PushColor(ImGuiCol.Text, valueColor.Value); @@ -687,9 +712,9 @@ public class CompactUi : WindowMediatorSubscriberBase ImGuiHelpers.ScaledDummy(4); } - var onlineUsers = users.Where(u => u.UserPair!.OtherPermissions.IsPaired() && (u.IsOnline || u.UserPair!.OwnPermissions.IsPaused())).Select(c => new DrawUserPair("Online" + c.UserData.UID, c, _uidDisplayHandler, _apiController, Mediator, _selectGroupForPairUi, _uiSharedService, _charaDataManager)).ToList(); - var visibleUsers = users.Where(u => u.IsVisible).Select(c => new DrawUserPair("Visible" + c.UserData.UID, c, _uidDisplayHandler, _apiController, Mediator, _selectGroupForPairUi, _uiSharedService, _charaDataManager)).ToList(); - var offlineUsers = users.Where(u => !u.UserPair!.OtherPermissions.IsPaired() || (!u.IsOnline && !u.UserPair!.OwnPermissions.IsPaused())).Select(c => new DrawUserPair("Offline" + c.UserData.UID, c, _uidDisplayHandler, _apiController, Mediator, _selectGroupForPairUi, _uiSharedService, _charaDataManager)).ToList(); + var onlineUsers = users.Where(u => u.UserPair!.OtherPermissions.IsPaired() && (u.IsOnline || u.UserPair!.OwnPermissions.IsPaused())).Select(c => new DrawUserPair("Online" + c.UserData.UID, c, _uidDisplayHandler, _apiController, Mediator, _selectGroupForPairUi, _uiSharedService, _charaDataManager, _serverManager)).ToList(); + var visibleUsers = users.Where(u => u.IsVisible).Select(c => new DrawUserPair("Visible" + c.UserData.UID, c, _uidDisplayHandler, _apiController, Mediator, _selectGroupForPairUi, _uiSharedService, _charaDataManager, _serverManager)).ToList(); + var offlineUsers = users.Where(u => !u.UserPair!.OtherPermissions.IsPaired() || (!u.IsOnline && !u.UserPair!.OwnPermissions.IsPaused())).Select(c => new DrawUserPair("Offline" + c.UserData.UID, c, _uidDisplayHandler, _apiController, Mediator, _selectGroupForPairUi, _uiSharedService, _charaDataManager, _serverManager)).ToList(); _pairGroupsUi.Draw(visibleUsers, onlineUsers, offlineUsers); diff --git a/MareSynchronos/UI/Components/DrawGroupPair.cs b/MareSynchronos/UI/Components/DrawGroupPair.cs index 488aafb..3e42d59 100644 --- a/MareSynchronos/UI/Components/DrawGroupPair.cs +++ b/MareSynchronos/UI/Components/DrawGroupPair.cs @@ -1,15 +1,21 @@ -using System.Numerics; +using System; +using System.Numerics; +using System.Text; +using System.Threading.Tasks; +using Dalamud.Game.Text.SeStringHandling; using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; +using MareSynchronos.API.Data; using MareSynchronos.API.Data.Enum; using MareSynchronos.API.Data.Extensions; using MareSynchronos.API.Dto.Group; -using MareSynchronos.API.Dto.User; using MareSynchronos.PlayerData.Pairs; using MareSynchronos.Services; +using MareSynchronos.Services.AutoDetect; using MareSynchronos.Services.Mediator; +using MareSynchronos.Services.ServerConfiguration; using MareSynchronos.UI.Handlers; using MareSynchronos.WebAPI; @@ -21,16 +27,22 @@ public class DrawGroupPair : DrawPairBase private readonly GroupPairFullInfoDto _fullInfoDto; private readonly GroupFullInfoDto _group; private readonly CharaDataManager _charaDataManager; + private readonly AutoDetectRequestService _autoDetectRequestService; + private readonly ServerConfigurationManager _serverConfigurationManager; + private const string ManualPairInvitePrefix = "[UmbraPairInvite|"; public DrawGroupPair(string id, Pair entry, ApiController apiController, MareMediator mareMediator, GroupFullInfoDto group, GroupPairFullInfoDto fullInfoDto, - UidDisplayHandler handler, UiSharedService uiSharedService, CharaDataManager charaDataManager) + UidDisplayHandler handler, UiSharedService uiSharedService, CharaDataManager charaDataManager, + AutoDetectRequestService autoDetectRequestService, ServerConfigurationManager serverConfigurationManager) : base(id, entry, apiController, handler, uiSharedService) { _group = group; _fullInfoDto = fullInfoDto; _mediator = mareMediator; _charaDataManager = charaDataManager; + _autoDetectRequestService = autoDetectRequestService; + _serverConfigurationManager = serverConfigurationManager; } protected override void DrawLeftSide(float textPosY, float originalY) @@ -153,8 +165,9 @@ public class DrawGroupPair : DrawPairBase bool showShared = _charaDataManager.SharedWithYouData.TryGetValue(_pair.UserData, out var sharedData); bool showInfo = (individualAnimDisabled || individualSoundsDisabled || animDisabled || soundsDisabled); - bool showPlus = _pair.UserPair == null; + bool showPlus = _pair.UserPair == null && _pair.IsOnline; bool showBars = (userIsOwner || (userIsModerator && !entryIsMod && !entryIsOwner)) || !_pair.IsPaused; + bool showPause = true; var spacing = ImGui.GetStyle().ItemSpacing.X; var permIcon = (individualAnimDisabled || individualSoundsDisabled || individualVFXDisabled) ? FontAwesomeIcon.ExclamationTriangle @@ -162,12 +175,15 @@ public class DrawGroupPair : DrawPairBase var runningIconWidth = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Running).X; var infoIconWidth = UiSharedService.GetIconSize(permIcon).X; var plusButtonWidth = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Plus).X; + var pauseIcon = _fullInfoDto.GroupUserPermissions.IsPaused() ? FontAwesomeIcon.Play : FontAwesomeIcon.Pause; + var pauseButtonWidth = _uiSharedService.GetIconButtonSize(pauseIcon).X; var barButtonWidth = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Bars).X; var pos = ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth() + spacing - (showShared ? (runningIconWidth + spacing) : 0) - (showInfo ? (infoIconWidth + spacing) : 0) - (showPlus ? (plusButtonWidth + spacing) : 0) + - (showPause ? (pauseButtonWidth + spacing) : 0) - (showBars ? (barButtonWidth + spacing) : 0); ImGui.SameLine(pos); @@ -280,9 +296,28 @@ public class DrawGroupPair : DrawPairBase if (_uiSharedService.IconButton(FontAwesomeIcon.Plus)) { - _ = _apiController.UserAddPair(new UserDto(new(_pair.UserData.UID))); + var targetUid = _pair.UserData.UID; + if (!string.IsNullOrEmpty(targetUid)) + { + _ = SendGroupPairInviteAsync(targetUid, entryUID); + } } - UiSharedService.AttachToolTip("Pair with " + entryUID + " individually"); + UiSharedService.AttachToolTip(AppendSeenInfo("Send pairing invite to " + entryUID)); + ImGui.SameLine(); + } + + if (showPause) + { + ImGui.SetCursorPosY(originalY); + + if (_uiSharedService.IconButton(pauseIcon)) + { + var newPermissions = _fullInfoDto.GroupUserPermissions ^ GroupUserPermissions.Paused; + _fullInfoDto.GroupUserPermissions = newPermissions; + _ = _apiController.GroupChangeIndividualPermissionState(new GroupPairUserPermissionDto(_group.Group, _pair.UserData, newPermissions)); + } + + UiSharedService.AttachToolTip(AppendSeenInfo((_fullInfoDto.GroupUserPermissions.IsPaused() ? "Resume" : "Pause") + " syncing with " + entryUID)); ImGui.SameLine(); } @@ -383,4 +418,74 @@ public class DrawGroupPair : DrawPairBase return pos - spacing; } + + private string AppendSeenInfo(string tooltip) + { + if (_pair.IsVisible) return tooltip; + + var lastSeen = _serverConfigurationManager.GetNameForUid(_pair.UserData.UID); + if (string.IsNullOrWhiteSpace(lastSeen)) return tooltip; + + return tooltip + " (Vu sous : " + lastSeen + ")"; + } + + private async Task SendGroupPairInviteAsync(string targetUid, string displayName) + { + try + { + var ok = await _autoDetectRequestService.SendDirectUidRequestAsync(targetUid, displayName).ConfigureAwait(false); + if (!ok) return; + + await SendManualInviteSignalAsync(targetUid, displayName).ConfigureAwait(false); + } + catch + { + // errors are logged within the request service; ignore here + } + } + + private async Task SendManualInviteSignalAsync(string targetUid, string displayName) + { + if (string.IsNullOrEmpty(_apiController.UID)) return; + + var senderAliasRaw = string.IsNullOrEmpty(_apiController.DisplayName) ? _apiController.UID : _apiController.DisplayName; + var senderAlias = EncodeInviteField(senderAliasRaw); + var targetDisplay = EncodeInviteField(displayName); + var inviteId = Guid.NewGuid().ToString("N"); + var payloadText = new StringBuilder() + .Append(ManualPairInvitePrefix) + .Append(_apiController.UID) + .Append('|') + .Append(senderAlias) + .Append('|') + .Append(targetUid) + .Append('|') + .Append(targetDisplay) + .Append('|') + .Append(inviteId) + .Append(']') + .ToString(); + + var payload = new SeStringBuilder().AddText(payloadText).Build().Encode(); + var chatMessage = new ChatMessage + { + SenderName = senderAlias, + PayloadContent = payload + }; + + try + { + await _apiController.GroupChatSendMsg(new GroupDto(_group.Group), chatMessage).ConfigureAwait(false); + } + catch + { + // ignore - invite remains tracked locally even if group chat signal fails + } + } + + private static string EncodeInviteField(string value) + { + var bytes = Encoding.UTF8.GetBytes(value ?? string.Empty); + return Convert.ToBase64String(bytes); + } } diff --git a/MareSynchronos/UI/Components/DrawUserPair.cs b/MareSynchronos/UI/Components/DrawUserPair.cs index f6e4b3c..69fbbb8 100644 --- a/MareSynchronos/UI/Components/DrawUserPair.cs +++ b/MareSynchronos/UI/Components/DrawUserPair.cs @@ -11,6 +11,7 @@ using MareSynchronos.Services.Mediator; using MareSynchronos.UI.Handlers; using MareSynchronos.WebAPI; using System.Numerics; +using MareSynchronos.Services.ServerConfiguration; namespace MareSynchronos.UI.Components; @@ -20,10 +21,12 @@ public class DrawUserPair : DrawPairBase protected readonly MareMediator _mediator; private readonly SelectGroupForPairUi _selectGroupForPairUi; private readonly CharaDataManager _charaDataManager; + private readonly ServerConfigurationManager _serverConfigurationManager; public DrawUserPair(string id, Pair entry, UidDisplayHandler displayHandler, ApiController apiController, MareMediator mareMediator, SelectGroupForPairUi selectGroupForPairUi, - UiSharedService uiSharedService, CharaDataManager charaDataManager) + UiSharedService uiSharedService, CharaDataManager charaDataManager, + ServerConfigurationManager serverConfigurationManager) : base(id, entry, apiController, displayHandler, uiSharedService) { if (_pair.UserPair == null) throw new ArgumentException("Pair must be UserPair", nameof(entry)); @@ -31,6 +34,7 @@ public class DrawUserPair : DrawPairBase _selectGroupForPairUi = selectGroupForPairUi; _mediator = mareMediator; _charaDataManager = charaDataManager; + _serverConfigurationManager = serverConfigurationManager; } public bool IsOnline => _pair.IsOnline; @@ -135,9 +139,9 @@ public class DrawUserPair : DrawPairBase perm.SetPaused(!perm.IsPaused()); _ = _apiController.UserSetPairPermissions(new(_pair.UserData, perm)); } - UiSharedService.AttachToolTip(!_pair.UserPair!.OwnPermissions.IsPaused() + UiSharedService.AttachToolTip(AppendSeenInfo(!_pair.UserPair!.OwnPermissions.IsPaused() ? "Pause pairing with " + entryUID - : "Resume pairing with " + entryUID); + : "Resume pairing with " + entryUID)); var individualSoundsDisabled = (_pair.UserPair?.OwnPermissions.IsDisableSounds() ?? false) || (_pair.UserPair?.OtherPermissions.IsDisableSounds() ?? false); @@ -263,7 +267,7 @@ public class DrawUserPair : DrawPairBase { _selectGroupForPairUi.Open(entry); } - UiSharedService.AttachToolTip("Choose pair groups for " + entryUID); + UiSharedService.AttachToolTip(AppendSeenInfo("Choose pair groups for " + entryUID)); var isDisableSounds = entry.UserPair!.OwnPermissions.IsDisableSounds(); string disableSoundsText = isDisableSounds ? "Enable sound sync" : "Disable sound sync"; @@ -302,6 +306,16 @@ public class DrawUserPair : DrawPairBase { _ = _apiController.UserRemovePair(new(entry.UserData)); } - UiSharedService.AttachToolTip("Hold CTRL and click to unpair permanently from " + entryUID); + UiSharedService.AttachToolTip(AppendSeenInfo("Hold CTRL and click to unpair permanently from " + entryUID)); + } + + private string AppendSeenInfo(string tooltip) + { + if (_pair.IsVisible) return tooltip; + + var lastSeen = _serverConfigurationManager.GetNameForUid(_pair.UserData.UID); + if (string.IsNullOrWhiteSpace(lastSeen)) return tooltip; + + return tooltip + " (Vu sous : " + lastSeen + ")"; } } diff --git a/MareSynchronos/UI/Components/GroupPanel.cs b/MareSynchronos/UI/Components/GroupPanel.cs index 07abb5b..5a71c80 100644 --- a/MareSynchronos/UI/Components/GroupPanel.cs +++ b/MareSynchronos/UI/Components/GroupPanel.cs @@ -12,6 +12,7 @@ using MareSynchronos.API.Dto.Group; using MareSynchronos.MareConfiguration; using MareSynchronos.PlayerData.Pairs; using MareSynchronos.Services; +using MareSynchronos.Services.AutoDetect; using MareSynchronos.Services.Mediator; using MareSynchronos.Services.ServerConfiguration; using MareSynchronos.UI.Components; @@ -32,6 +33,7 @@ internal sealed class GroupPanel private readonly MareConfigService _mareConfig; private readonly ServerConfigurationManager _serverConfigurationManager; private readonly CharaDataManager _charaDataManager; + private readonly AutoDetectRequestService _autoDetectRequestService; private readonly Dictionary _showGidForEntry = new(StringComparer.Ordinal); private readonly UidDisplayHandler _uidDisplayHandler; private readonly UiSharedService _uiShared; @@ -74,7 +76,7 @@ internal sealed class GroupPanel public GroupPanel(CompactUi mainUi, UiSharedService uiShared, PairManager pairManager, ChatService chatServivce, UidDisplayHandler uidDisplayHandler, MareConfigService mareConfig, ServerConfigurationManager serverConfigurationManager, - CharaDataManager charaDataManager) + CharaDataManager charaDataManager, AutoDetectRequestService autoDetectRequestService) { _mainUi = mainUi; _uiShared = uiShared; @@ -84,6 +86,7 @@ internal sealed class GroupPanel _mareConfig = mareConfig; _serverConfigurationManager = serverConfigurationManager; _charaDataManager = charaDataManager; + _autoDetectRequestService = autoDetectRequestService; } private ApiController ApiController => _uiShared.ApiController; @@ -566,7 +569,9 @@ internal sealed class GroupPanel ).Value, _uidDisplayHandler, _uiShared, - _charaDataManager); + _charaDataManager, + _autoDetectRequestService, + _serverConfigurationManager); if (pair.IsVisible) visibleUsers.Add(drawPair); diff --git a/MareSynchronos/UI/SettingsUi.cs b/MareSynchronos/UI/SettingsUi.cs index 898b3b8..c0015cf 100644 --- a/MareSynchronos/UI/SettingsUi.cs +++ b/MareSynchronos/UI/SettingsUi.cs @@ -209,12 +209,24 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.AlignTextToFramePadding(); ImGui.TextUnformatted("0 = No limit/infinite"); + bool enableDownloadQueue = _configService.Current.EnableDownloadQueue; + if (ImGui.Checkbox("Activer la file de téléchargements", ref enableDownloadQueue)) + { + _configService.Current.EnableDownloadQueue = enableDownloadQueue; + _configService.Save(); + } + UiSharedService.AttachToolTip("Lance les téléchargements de personnages de manière séquentielle plutôt que tous en même temps. " + + "Quand l'option est activée, seul le nombre configuré de téléchargements fonctionne en parallèle."); + ImGui.SetNextItemWidth(250 * ImGuiHelpers.GlobalScale); if (ImGui.SliderInt("Maximum Parallel Downloads", ref maxParallelDownloads, 1, 10)) { _configService.Current.ParallelDownloads = maxParallelDownloads; _configService.Save(); } + UiSharedService.AttachToolTip(enableDownloadQueue + ? "Nombre maximal de téléchargements de personnages autorisés simultanément lorsque la file est activée." + : "Nombre maximal de flux de fichiers lancés en parallèle pour chaque téléchargement."); ImGui.Separator(); _uiShared.BigText("AutoDetect"); @@ -222,7 +234,7 @@ public class SettingsUi : WindowMediatorSubscriberBase bool enableDiscovery = _configService.Current.EnableAutoDetectDiscovery; using (ImRaii.Disabled(isAutoDetectSuppressed)) { - if (ImGui.Checkbox("Enable AutoDetect", ref enableDiscovery)) + if (ImGui.Checkbox("Activer l'AutoDetect", ref enableDiscovery)) { _configService.Current.EnableAutoDetectDiscovery = enableDiscovery; _configService.Save(); @@ -248,7 +260,7 @@ public class SettingsUi : WindowMediatorSubscriberBase using (ImRaii.Disabled(isAutoDetectSuppressed || !enableDiscovery)) { bool allowRequests = _configService.Current.AllowAutoDetectPairRequests; - if (ImGui.Checkbox("Allow pair requests", ref allowRequests)) + if (ImGui.Checkbox("Activer les invitations entrantes", ref allowRequests)) { _configService.Current.AllowAutoDetectPairRequests = allowRequests; _configService.Save(); @@ -275,7 +287,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Indent(); int maxMeters = _configService.Current.AutoDetectMaxDistanceMeters; ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale); - if (ImGui.SliderInt("Max distance (meters)", ref maxMeters, 5, 100)) + if (ImGui.SliderInt("Distance max (en mètre)", ref maxMeters, 5, 100)) { _configService.Current.AutoDetectMaxDistanceMeters = maxMeters; _configService.Save(); diff --git a/MareSynchronos/UI/TypingIndicatorOverlay.cs b/MareSynchronos/UI/TypingIndicatorOverlay.cs index fef7edf..d1c5fe8 100644 --- a/MareSynchronos/UI/TypingIndicatorOverlay.cs +++ b/MareSynchronos/UI/TypingIndicatorOverlay.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; using System.Numerics; +using System.Linq; using Dalamud.Bindings.ImGui; using Dalamud.Interface.Utility; using Dalamud.Plugin.Services; using MareSynchronos.API.Data; +using MareSynchronos.API.Data.Extensions; using MareSynchronos.MareConfiguration; using MareSynchronos.MareConfiguration.Models; using MareSynchronos.PlayerData.Pairs; @@ -115,25 +117,34 @@ public sealed class TypingIndicatorOverlay : WindowMediatorSubscriberBase continue; var pair = _pairManager.GetPairByUID(uid); - if (pair == null) - { - var alias = entry.User.AliasOrUID; - if (string.IsNullOrEmpty(alias)) - continue; + var targetIndex = -1; + var playerName = pair?.PlayerName; + var objectId = pair?.PlayerCharacterId ?? uint.MaxValue; - var aliasIndex = GetPartyIndexForName(alias); - if (aliasIndex >= 0) + if (objectId != 0 && objectId != uint.MaxValue) + { + targetIndex = GetPartyIndexForObjectId(objectId); + if (targetIndex >= 0 && !string.IsNullOrEmpty(playerName)) { - DrawPartyMemberTyping(drawList, partyAddon, aliasIndex); + var member = _partyList[targetIndex]; + var memberName = member?.Name?.TextValue; + if (!string.IsNullOrEmpty(memberName) && !memberName.Equals(playerName, StringComparison.OrdinalIgnoreCase)) + { + var nameIndex = GetPartyIndexForName(playerName); + targetIndex = nameIndex; + } } - continue; } - var index = GetPartyIndexForObjectId(pair.PlayerCharacterId); - if (index < 0) + if (targetIndex < 0 && !string.IsNullOrEmpty(playerName)) + { + targetIndex = GetPartyIndexForName(playerName); + } + + if (targetIndex < 0) continue; - DrawPartyMemberTyping(drawList, partyAddon, index); + DrawPartyMemberTyping(drawList, partyAddon, targetIndex); } } @@ -198,10 +209,10 @@ public sealed class TypingIndicatorOverlay : WindowMediatorSubscriberBase 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 pairName = pair?.PlayerName ?? entry.User.AliasOrUID ?? string.Empty; + var pairIdent = pair?.Ident ?? string.Empty; + var isPartyMember = IsPartyMember(objectId, pairName); + var isRelevantMember = IsPlayerRelevant(pair, isPartyMember); if (objectId != uint.MaxValue && objectId != 0 && TryDrawNameplateBubble(drawList, iconWrap, objectId)) { @@ -209,20 +220,28 @@ public sealed class TypingIndicatorOverlay : WindowMediatorSubscriberBase continue; } - var pairName = pair?.PlayerName ?? entry.User.AliasOrUID ?? string.Empty; - var pairIdent = pair?.Ident ?? string.Empty; + var hasWorldPosition = TryResolveWorldPosition(pair, entry.User, objectId, out var worldPos); + var isNearby = hasWorldPosition && IsWithinRelevantDistance(worldPos); - _logger.LogInformation("TypingIndicator: fallback draw for {uid} (objectId={objectId}, name={name}, ident={ident})", + if (!isRelevantMember && !isNearby) + continue; + + if (pair == null) + { + _logger.LogTrace("TypingIndicator: no pair found for {uid}, attempting fallback", uid); + } + + _logger.LogTrace("TypingIndicator: fallback draw for {uid} (objectId={objectId}, name={name}, ident={ident})", uid, objectId, pairName, pairIdent); - if (TryResolveWorldPosition(pair, entry.User, objectId, out var worldPos)) + if (hasWorldPosition) { DrawWorldFallbackIcon(drawList, iconWrap, worldPos); - _logger.LogInformation("TypingIndicator: fallback world draw for {uid} at {pos}", uid, worldPos); + _logger.LogTrace("TypingIndicator: fallback world draw for {uid} at {pos}", uid, worldPos); } else { - _logger.LogInformation("TypingIndicator: could not resolve position for {uid}", uid); + _logger.LogTrace("TypingIndicator: could not resolve position for {uid}", uid); } } } @@ -464,6 +483,48 @@ public sealed class TypingIndicatorOverlay : WindowMediatorSubscriberBase return -1; } + private bool IsPartyMember(uint objectId, string? playerName) + { + if (objectId != 0 && objectId != uint.MaxValue && GetPartyIndexForObjectId(objectId) >= 0) + return true; + + if (!string.IsNullOrEmpty(playerName) && GetPartyIndexForName(playerName) >= 0) + return true; + + return false; + } + + private bool IsPlayerRelevant(Pair? pair, bool isPartyMember) + { + if (isPartyMember) + return true; + + if (pair?.UserPair != null) + { + var userPair = pair.UserPair; + if (userPair.OtherPermissions.IsPaired() || userPair.OwnPermissions.IsPaired()) + return true; + } + + if (pair?.GroupPair != null && pair.GroupPair.Any(g => + !g.Value.GroupUserPermissions.IsPaused() && + !g.Key.GroupUserPermissions.IsPaused())) + { + return true; + } + + return false; + } + + private bool IsWithinRelevantDistance(Vector3 position) + { + if (_clientState.LocalPlayer == null) + return false; + + var distance = Vector3.Distance(_clientState.LocalPlayer.Position, position); + return distance <= 40f; + } + private static unsafe uint GetEntityId(nint address) { if (address == nint.Zero) return 0; diff --git a/MareSynchronos/WebAPI/AutoDetect/DiscoveryApiClient.cs b/MareSynchronos/WebAPI/AutoDetect/DiscoveryApiClient.cs index 740696f..4f99e67 100644 --- a/MareSynchronos/WebAPI/AutoDetect/DiscoveryApiClient.cs +++ b/MareSynchronos/WebAPI/AutoDetect/DiscoveryApiClient.cs @@ -1,6 +1,7 @@ using System.Net.Http.Headers; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using MareSynchronos.WebAPI.SignalR; using MareSynchronos.Services.AutoDetect; @@ -65,15 +66,21 @@ public class DiscoveryApiClient } } - public async Task SendRequestAsync(string endpoint, string token, string? displayName, CancellationToken ct) + public async Task SendRequestAsync(string endpoint, string? token, string? targetUid, string? displayName, CancellationToken ct) { try { + if (string.IsNullOrEmpty(token) && string.IsNullOrEmpty(targetUid)) + { + _logger.LogWarning("Discovery request aborted: no token or targetUid provided"); + return false; + } + var jwt = await _tokenProvider.GetOrUpdateToken(ct).ConfigureAwait(false); if (string.IsNullOrEmpty(jwt)) return false; using var req = new HttpRequestMessage(HttpMethod.Post, endpoint); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); - var body = JsonSerializer.Serialize(new { token, displayName }); + var body = JsonSerializer.Serialize(new RequestPayload(token, targetUid, displayName)); req.Content = new StringContent(body, Encoding.UTF8, "application/json"); var resp = await _httpClient.SendAsync(req, ct).ConfigureAwait(false); if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized) @@ -82,7 +89,7 @@ public class DiscoveryApiClient if (string.IsNullOrEmpty(jwt2)) return false; using var req2 = new HttpRequestMessage(HttpMethod.Post, endpoint); req2.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt2); - var body2 = JsonSerializer.Serialize(new { token, displayName }); + var body2 = JsonSerializer.Serialize(new RequestPayload(token, targetUid, displayName)); req2.Content = new StringContent(body2, Encoding.UTF8, "application/json"); resp = await _httpClient.SendAsync(req2, ct).ConfigureAwait(false); } @@ -102,6 +109,14 @@ public class DiscoveryApiClient } } + private sealed record RequestPayload( + [property: JsonPropertyName("token"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? Token, + [property: JsonPropertyName("targetUid"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? TargetUid, + [property: JsonPropertyName("displayName"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? DisplayName); + public async Task PublishAsync(string endpoint, IEnumerable hashes, string? displayName, CancellationToken ct, bool allowRequests = true) { try diff --git a/MareSynchronos/WebAPI/Files/FileDownloadManager.cs b/MareSynchronos/WebAPI/Files/FileDownloadManager.cs index 3020064..035fae7 100644 --- a/MareSynchronos/WebAPI/Files/FileDownloadManager.cs +++ b/MareSynchronos/WebAPI/Files/FileDownloadManager.cs @@ -4,6 +4,7 @@ using MareSynchronos.API.Data; using MareSynchronos.API.Dto.Files; using MareSynchronos.API.Routes; using MareSynchronos.FileCache; +using MareSynchronos.MareConfiguration; using MareSynchronos.PlayerData.Handlers; using MareSynchronos.Services.Mediator; using MareSynchronos.Utils; @@ -20,17 +21,22 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase private readonly Dictionary _downloadStatus; private readonly FileCompactor _fileCompactor; private readonly FileCacheManager _fileDbManager; + private readonly MareConfigService _mareConfigService; private readonly FileTransferOrchestrator _orchestrator; private readonly List _activeDownloadStreams; + private readonly object _queueLock = new(); + private SemaphoreSlim? _downloadQueueSemaphore; + private int _downloadQueueCapacity = -1; public FileDownloadManager(ILogger logger, MareMediator mediator, FileTransferOrchestrator orchestrator, - FileCacheManager fileCacheManager, FileCompactor fileCompactor) : base(logger, mediator) + FileCacheManager fileCacheManager, FileCompactor fileCompactor, MareConfigService mareConfigService) : base(logger, mediator) { _downloadStatus = new Dictionary(StringComparer.Ordinal); _orchestrator = orchestrator; _fileDbManager = fileCacheManager; _fileCompactor = fileCompactor; + _mareConfigService = mareConfigService; _activeDownloadStreams = []; Mediator.Subscribe(this, (msg) => @@ -59,6 +65,14 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase public async Task DownloadFiles(GameObjectHandler gameObject, List fileReplacementDto, CancellationToken ct) { + SemaphoreSlim? queueSemaphore = null; + if (_mareConfigService.Current.EnableDownloadQueue) + { + queueSemaphore = GetQueueSemaphore(); + Logger.LogTrace("Queueing download for {name}. Currently queued: {queued}", gameObject.Name, queueSemaphore.CurrentCount); + await queueSemaphore.WaitAsync(ct).ConfigureAwait(false); + } + Mediator.Publish(new HaltScanMessage(nameof(DownloadFiles))); try { @@ -70,6 +84,11 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase } finally { + if (queueSemaphore != null) + { + queueSemaphore.Release(); + } + Mediator.Publish(new DownloadFinishedMessage(gameObject)); Mediator.Publish(new ResumeScanMessage(nameof(DownloadFiles))); } @@ -132,6 +151,22 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase return (string.Join("", hashName), long.Parse(string.Join("", fileLength))); } + private SemaphoreSlim GetQueueSemaphore() + { + var desiredCapacity = Math.Clamp(_mareConfigService.Current.ParallelDownloads, 1, 10); + + lock (_queueLock) + { + if (_downloadQueueSemaphore == null || _downloadQueueCapacity != desiredCapacity) + { + _downloadQueueSemaphore = new SemaphoreSlim(desiredCapacity, desiredCapacity); + _downloadQueueCapacity = desiredCapacity; + } + + return _downloadQueueSemaphore; + } + } + private async Task DownloadAndMungeFileHttpClient(string downloadGroup, Guid requestId, List fileTransfer, string tempPath, IProgress progress, CancellationToken ct) { Logger.LogDebug("GUID {requestId} on server {uri} for files {files}", requestId, fileTransfer[0].DownloadUri, string.Join(", ", fileTransfer.Select(c => c.Hash).ToList()));