diff --git a/MareSynchronos/Plugin.cs b/MareSynchronos/Plugin.cs index 2763ba6..87d2e40 100644 --- a/MareSynchronos/Plugin.cs +++ b/MareSynchronos/Plugin.cs @@ -15,6 +15,7 @@ using MareSynchronos.Services; using MareSynchronos.Services.Events; using MareSynchronos.Services.Mediator; using MareSynchronos.Services.ServerConfiguration; +using MareSynchronos.Services.Notifications; using MareSynchronos.UI; using MareSynchronos.UI.Components; using MareSynchronos.UI.Components.Popup; @@ -102,6 +103,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); + collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); @@ -126,6 +128,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); + collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); @@ -151,6 +154,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)); @@ -186,6 +190,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddScoped(); collection.AddScoped(); collection.AddScoped(); + collection.AddScoped(); collection.AddScoped(); collection.AddScoped(sp => sp.GetRequiredService()); collection.AddScoped(sp => sp.GetRequiredService()); @@ -227,6 +232,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddHostedService(p => p.GetRequiredService()); collection.AddHostedService(p => p.GetRequiredService()); collection.AddHostedService(p => p.GetRequiredService()); + collection.AddHostedService(p => p.GetRequiredService()); collection.AddHostedService(p => p.GetRequiredService()); collection.AddHostedService(p => p.GetRequiredService()); }) diff --git a/MareSynchronos/Services/AutoDetect/NearbyPendingService.cs b/MareSynchronos/Services/AutoDetect/NearbyPendingService.cs index b0c45f4..5c306c7 100644 --- a/MareSynchronos/Services/AutoDetect/NearbyPendingService.cs +++ b/MareSynchronos/Services/AutoDetect/NearbyPendingService.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Text.RegularExpressions; using MareSynchronos.MareConfiguration.Models; using MareSynchronos.Services.Mediator; +using MareSynchronos.Services.Notifications; using MareSynchronos.WebAPI; using Microsoft.Extensions.Logging; @@ -14,17 +15,19 @@ public sealed class NearbyPendingService : IMediatorSubscriber private readonly MareMediator _mediator; private readonly ApiController _api; private readonly AutoDetectRequestService _requestService; + private readonly NotificationTracker _notificationTracker; private readonly ConcurrentDictionary _pending = new(StringComparer.Ordinal); private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(1); private static readonly Regex ReqRegex = new(@"^Nearby Request: .+ \[(?[A-Z0-9]+)\]$", RegexOptions.Compiled | RegexOptions.ExplicitCapture, RegexTimeout); private static readonly Regex AcceptRegex = new(@"^Nearby Accept: .+ \[(?[A-Z0-9]+)\]$", RegexOptions.Compiled | RegexOptions.ExplicitCapture, RegexTimeout); - public NearbyPendingService(ILogger logger, MareMediator mediator, ApiController api, AutoDetectRequestService requestService) + public NearbyPendingService(ILogger logger, MareMediator mediator, ApiController api, AutoDetectRequestService requestService, NotificationTracker notificationTracker) { _logger = logger; _mediator = mediator; _api = api; _requestService = requestService; + _notificationTracker = notificationTracker; _mediator.Subscribe(this, OnNotification); _mediator.Subscribe(this, OnManualPairInvite); } @@ -46,6 +49,7 @@ public sealed class NearbyPendingService : IMediatorSubscriber _ = _api.UserAddPair(new MareSynchronos.API.Dto.User.UserDto(new MareSynchronos.API.Data.UserData(uidA))); _pending.TryRemove(uidA, out _); _requestService.RemovePendingRequestByUid(uidA); + _notificationTracker.Remove(NotificationCategory.AutoDetect, uidA); _logger.LogInformation("NearbyPending: auto-accepted pairing with {uid}", uidA); } return; @@ -67,6 +71,7 @@ public sealed class NearbyPendingService : IMediatorSubscriber catch { name = uid; } _pending[uid] = name; _logger.LogInformation("NearbyPending: received request from {uid} ({name})", uid, name); + _notificationTracker.Upsert(NotificationEntry.AutoDetect(uid, name)); } private void OnManualPairInvite(ManualPairInviteMessage msg) @@ -81,12 +86,14 @@ public sealed class NearbyPendingService : IMediatorSubscriber _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))); + _notificationTracker.Upsert(NotificationEntry.AutoDetect(msg.SourceUid, display)); } public void Remove(string uid) { _pending.TryRemove(uid, out _); _requestService.RemovePendingRequestByUid(uid); + _notificationTracker.Remove(NotificationCategory.AutoDetect, uid); } public async Task AcceptAsync(string uid) @@ -97,6 +104,7 @@ public sealed class NearbyPendingService : IMediatorSubscriber _pending.TryRemove(uid, out _); _requestService.RemovePendingRequestByUid(uid); _ = _requestService.SendAcceptNotifyAsync(uid); + _notificationTracker.Remove(NotificationCategory.AutoDetect, uid); return true; } catch (Exception ex) diff --git a/MareSynchronos/Services/AutoDetect/SyncshellDiscoveryService.cs b/MareSynchronos/Services/AutoDetect/SyncshellDiscoveryService.cs new file mode 100644 index 0000000..67394b7 --- /dev/null +++ b/MareSynchronos/Services/AutoDetect/SyncshellDiscoveryService.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MareSynchronos.API.Data; +using MareSynchronos.API.Dto.Group; +using MareSynchronos.Services.Mediator; +using MareSynchronos.WebAPI; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MareSynchronos.Services.AutoDetect; + +public sealed class SyncshellDiscoveryService : IHostedService, IMediatorSubscriber +{ + private readonly ILogger _logger; + private readonly MareMediator _mediator; + private readonly ApiController _apiController; + private readonly SemaphoreSlim _refreshSemaphore = new(1, 1); + private readonly object _entriesLock = new(); + private List _entries = []; + private string? _lastError; + private bool _isRefreshing; + + public SyncshellDiscoveryService(ILogger logger, MareMediator mediator, ApiController apiController) + { + _logger = logger; + _mediator = mediator; + _apiController = apiController; + } + + public MareMediator Mediator => _mediator; + + public IReadOnlyList Entries + { + get + { + lock (_entriesLock) + { + return _entries.ToList(); + } + } + } + + public bool IsRefreshing => _isRefreshing; + public string? LastError => _lastError; + + public async Task JoinAsync(string gid, CancellationToken ct) + { + try + { + return await _apiController.SyncshellDiscoveryJoin(new GroupDto(new GroupData(gid))).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Join syncshell discovery failed for {gid}", gid); + return false; + } + } + + public async Task GetStateAsync(string gid, CancellationToken ct) + { + try + { + return await _apiController.SyncshellDiscoveryGetState(new GroupDto(new GroupData(gid))).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch syncshell discovery state for {gid}", gid); + return null; + } + } + + public async Task SetVisibilityAsync(string gid, bool visible, CancellationToken ct) + { + try + { + var request = new SyncshellDiscoveryVisibilityRequestDto + { + GID = gid, + AutoDetectVisible = visible, + }; + var success = await _apiController.SyncshellDiscoverySetVisibility(request).ConfigureAwait(false); + if (!success) return false; + + var state = await GetStateAsync(gid, ct).ConfigureAwait(false); + if (state != null) + { + _mediator.Publish(new SyncshellAutoDetectStateChanged(state.GID, state.AutoDetectVisible, state.PasswordTemporarilyDisabled)); + } + + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to set syncshell visibility for {gid}", gid); + return false; + } + } + + public async Task RefreshAsync(CancellationToken ct) + { + if (!await _refreshSemaphore.WaitAsync(0, ct).ConfigureAwait(false)) + { + return; + } + + try + { + _isRefreshing = true; + var discovered = await _apiController.SyncshellDiscoveryList().ConfigureAwait(false); + lock (_entriesLock) + { + _entries = discovered ?? []; + } + _lastError = null; + _mediator.Publish(new SyncshellDiscoveryUpdated(Entries.ToList())); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to refresh syncshell discovery list"); + _lastError = ex.Message; + } + finally + { + _isRefreshing = false; + _refreshSemaphore.Release(); + } + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _mediator.Subscribe(this, msg => + { + _ = RefreshAsync(CancellationToken.None); + }); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _mediator.UnsubscribeAll(this); + return Task.CompletedTask; + } +} diff --git a/MareSynchronos/Services/CharaData/CharaDataFileHandler.cs b/MareSynchronos/Services/CharaData/CharaDataFileHandler.cs index 6bb1297..221a984 100644 --- a/MareSynchronos/Services/CharaData/CharaDataFileHandler.cs +++ b/MareSynchronos/Services/CharaData/CharaDataFileHandler.cs @@ -11,6 +11,7 @@ using MareSynchronos.Services.CharaData.Models; using MareSynchronos.Utils; using MareSynchronos.WebAPI.Files; using Microsoft.Extensions.Logging; +using System.Threading; namespace MareSynchronos.Services; @@ -295,6 +296,32 @@ public sealed class CharaDataFileHandler : IDisposable } } + internal async Task CreateCharaFileBytesAsync(string description, CancellationToken token = default) + { + var tempFilePath = Path.Combine(Path.GetTempPath(), "umbra_mcdfshare_" + Guid.NewGuid().ToString("N") + ".mcdf"); + try + { + await SaveCharaFileAsync(description, tempFilePath).ConfigureAwait(false); + if (!File.Exists(tempFilePath)) return null; + token.ThrowIfCancellationRequested(); + return await File.ReadAllBytesAsync(tempFilePath, token).ConfigureAwait(false); + } + finally + { + try + { + if (File.Exists(tempFilePath)) + { + File.Delete(tempFilePath); + } + } + catch + { + // ignored + } + } + } + internal async Task> UploadFiles(List fileList, ValueProgress uploadProgress, CancellationToken token) { return await _fileUploadManager.UploadFiles(fileList, uploadProgress, token).ConfigureAwait(false); diff --git a/MareSynchronos/Services/CharaData/CharaDataManager.cs b/MareSynchronos/Services/CharaData/CharaDataManager.cs index d87a9ca..80092d6 100644 --- a/MareSynchronos/Services/CharaData/CharaDataManager.cs +++ b/MareSynchronos/Services/CharaData/CharaDataManager.cs @@ -13,7 +13,9 @@ using MareSynchronos.Utils; using MareSynchronos.WebAPI; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; +using System.IO; using System.Text; +using System.Threading; namespace MareSynchronos.Services; @@ -457,6 +459,14 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase LoadedMcdfHeader = _fileHandler.LoadCharaFileHeader(filePath); } + public async Task LoadMcdfFromBytes(byte[] data, CancellationToken token = default) + { + var tempFilePath = Path.Combine(Path.GetTempPath(), "umbra_mcdfshare_" + Guid.NewGuid().ToString("N") + ".mcdf"); + await File.WriteAllBytesAsync(tempFilePath, data, token).ConfigureAwait(false); + LoadedMcdfHeader = _fileHandler.LoadCharaFileHeader(tempFilePath); + return tempFilePath; + } + public void McdfApplyToTarget(string charaName) { if (LoadedMcdfHeader == null || !LoadedMcdfHeader.IsCompletedSuccessfully) return; diff --git a/MareSynchronos/Services/CharaData/McdfShareManager.cs b/MareSynchronos/Services/CharaData/McdfShareManager.cs new file mode 100644 index 0000000..288e943 --- /dev/null +++ b/MareSynchronos/Services/CharaData/McdfShareManager.cs @@ -0,0 +1,309 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using MareSynchronos.API.Dto.McdfShare; +using MareSynchronos.MareConfiguration.Models; +using MareSynchronos.Services.ServerConfiguration; +using MareSynchronos.WebAPI; +using Microsoft.Extensions.Logging; + +namespace MareSynchronos.Services.CharaData; + +public sealed class McdfShareManager +{ + private readonly ILogger _logger; + private readonly ApiController _apiController; + private readonly CharaDataFileHandler _fileHandler; + private readonly CharaDataManager _charaDataManager; + private readonly ServerConfigurationManager _serverConfigurationManager; + private readonly SemaphoreSlim _operationSemaphore = new(1, 1); + private readonly List _ownShares = new(); + private readonly List _sharedWithMe = new(); + private Task? _currentTask; + + public McdfShareManager(ILogger logger, ApiController apiController, + CharaDataFileHandler fileHandler, CharaDataManager charaDataManager, + ServerConfigurationManager serverConfigurationManager) + { + _logger = logger; + _apiController = apiController; + _fileHandler = fileHandler; + _charaDataManager = charaDataManager; + _serverConfigurationManager = serverConfigurationManager; + } + + public IReadOnlyList OwnShares => _ownShares; + public IReadOnlyList SharedShares => _sharedWithMe; + public bool IsBusy => _currentTask is { IsCompleted: false }; + public string? LastError { get; private set; } + public string? LastSuccess { get; private set; } + + public Task RefreshAsync(CancellationToken token) + { + return RunOperation(() => InternalRefreshAsync(token)); + } + + public Task CreateShareAsync(string description, IReadOnlyList allowedIndividuals, IReadOnlyList allowedSyncshells, DateTime? expiresAtUtc, CancellationToken token) + { + return RunOperation(async () => + { + token.ThrowIfCancellationRequested(); + + var mcdfBytes = await _fileHandler.CreateCharaFileBytesAsync(description, token).ConfigureAwait(false); + if (mcdfBytes == null || mcdfBytes.Length == 0) + { + LastError = "Impossible de préparer les données MCDF."; + return; + } + + var secretKey = _serverConfigurationManager.GetSecretKey(out bool hasMultiple); + if (hasMultiple) + { + LastError = "Plusieurs clés secrètes sont configurées pour ce personnage. Corrigez cela dans les paramètres."; + return; + } + + if (string.IsNullOrEmpty(secretKey)) + { + LastError = "Aucune clé secrète n'est configurée pour ce personnage."; + return; + } + + var shareId = Guid.NewGuid(); + byte[] salt = RandomNumberGenerator.GetBytes(16); + byte[] nonce = RandomNumberGenerator.GetBytes(12); + byte[] key = DeriveKey(secretKey, shareId, salt); + + byte[] cipher = new byte[mcdfBytes.Length]; + byte[] tag = new byte[16]; + + using (var aes = new AesGcm(key, 16)) + { + aes.Encrypt(nonce, mcdfBytes, cipher, tag); + } + + var uploadDto = new McdfShareUploadRequestDto + { + ShareId = shareId, + Description = description, + CipherData = cipher, + Nonce = nonce, + Salt = salt, + Tag = tag, + ExpiresAtUtc = expiresAtUtc, + AllowedIndividuals = allowedIndividuals.ToList(), + AllowedSyncshells = allowedSyncshells.ToList() + }; + + await _apiController.McdfShareUpload(uploadDto).ConfigureAwait(false); + await InternalRefreshAsync(token).ConfigureAwait(false); + LastSuccess = "Partage MCDF créé."; + }); + } + + public Task DeleteShareAsync(Guid shareId) + { + return RunOperation(async () => + { + var result = await _apiController.McdfShareDelete(shareId).ConfigureAwait(false); + if (!result) + { + LastError = "Le serveur a refusé de supprimer le partage MCDF."; + return; + } + + _ownShares.RemoveAll(s => s.Id == shareId); + _sharedWithMe.RemoveAll(s => s.Id == shareId); + await InternalRefreshAsync(CancellationToken.None).ConfigureAwait(false); + LastSuccess = "Partage MCDF supprimé."; + }); + } + + public Task UpdateShareAsync(McdfShareUpdateRequestDto updateRequest) + { + return RunOperation(async () => + { + var updated = await _apiController.McdfShareUpdate(updateRequest).ConfigureAwait(false); + if (updated == null) + { + LastError = "Le serveur a refusé de mettre à jour le partage MCDF."; + return; + } + + var idx = _ownShares.FindIndex(s => s.Id == updated.Id); + if (idx >= 0) + { + _ownShares[idx] = updated; + } + LastSuccess = "Partage MCDF mis à jour."; + }); + } + + public Task ApplyShareAsync(Guid shareId, CancellationToken token) + { + return RunOperation(async () => + { + token.ThrowIfCancellationRequested(); + var plainBytes = await DownloadAndDecryptShareAsync(shareId, token).ConfigureAwait(false); + if (plainBytes == null) + { + LastError ??= "Échec du téléchargement du partage MCDF."; + return; + } + + var tempPath = await _charaDataManager.LoadMcdfFromBytes(plainBytes, token).ConfigureAwait(false); + try + { + await _charaDataManager.McdfApplyToGposeTarget().ConfigureAwait(false); + } + finally + { + try + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + catch + { + // ignored + } + } + LastSuccess = "Partage MCDF appliqué sur la cible GPose."; + }); + } + + public Task ExportShareAsync(Guid shareId, string filePath, CancellationToken token) + { + return RunOperation(async () => + { + token.ThrowIfCancellationRequested(); + var plainBytes = await DownloadAndDecryptShareAsync(shareId, token).ConfigureAwait(false); + if (plainBytes == null) + { + LastError ??= "Échec du téléchargement du partage MCDF."; + return; + } + + var directory = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + await File.WriteAllBytesAsync(filePath, plainBytes, token).ConfigureAwait(false); + LastSuccess = "Partage MCDF exporté."; + }); + } + + public Task DownloadShareToFileAsync(McdfShareEntryDto entry, string filePath, CancellationToken token) + { + return ExportShareAsync(entry.Id, filePath, token); + } + + private async Task DownloadAndDecryptShareAsync(Guid shareId, CancellationToken token) + { + var payload = await _apiController.McdfShareDownload(shareId).ConfigureAwait(false); + if (payload == null) + { + LastError = "Partage indisponible."; + return null; + } + + var secretKey = _serverConfigurationManager.GetSecretKey(out bool hasMultiple); + if (hasMultiple) + { + LastError = "Plusieurs clés secrètes sont configurées pour ce personnage."; + return null; + } + + if (string.IsNullOrEmpty(secretKey)) + { + LastError = "Aucune clé secrète n'est configurée pour ce personnage."; + return null; + } + + byte[] key = DeriveKey(secretKey, payload.ShareId, payload.Salt); + byte[] plaintext = new byte[payload.CipherData.Length]; + try + { + using var aes = new AesGcm(key, 16); + aes.Decrypt(payload.Nonce, payload.CipherData, payload.Tag, plaintext); + } + catch (CryptographicException ex) + { + _logger.LogWarning(ex, "Failed to decrypt MCDF share {ShareId}", shareId); + LastError = "Impossible de déchiffrer le partage MCDF."; + return null; + } + + token.ThrowIfCancellationRequested(); + return plaintext; + } + + private async Task InternalRefreshAsync(CancellationToken token) + { + token.ThrowIfCancellationRequested(); + var own = await _apiController.McdfShareGetOwn().ConfigureAwait(false); + token.ThrowIfCancellationRequested(); + var shared = await _apiController.McdfShareGetShared().ConfigureAwait(false); + _ownShares.Clear(); + _ownShares.AddRange(own); + _sharedWithMe.Clear(); + _sharedWithMe.AddRange(shared); + LastSuccess = "Partages MCDF actualisés."; + } + + private Task RunOperation(Func operation) + { + async Task Wrapper() + { + await _operationSemaphore.WaitAsync().ConfigureAwait(false); + try + { + LastError = null; + LastSuccess = null; + await operation().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during MCDF share operation"); + LastError = ex.Message; + } + finally + { + _operationSemaphore.Release(); + } + } + + var task = Wrapper(); + _currentTask = task; + return task; + } + + private static byte[] DeriveKey(string secretKey, Guid shareId, byte[] salt) + { + byte[] secretBytes; + try + { + secretBytes = Convert.FromHexString(secretKey); + } + catch (FormatException) + { + // fallback to UTF8 if not hex + secretBytes = System.Text.Encoding.UTF8.GetBytes(secretKey); + } + + byte[] shareBytes = shareId.ToByteArray(); + byte[] material = new byte[secretBytes.Length + shareBytes.Length + salt.Length]; + Buffer.BlockCopy(secretBytes, 0, material, 0, secretBytes.Length); + Buffer.BlockCopy(shareBytes, 0, material, secretBytes.Length, shareBytes.Length); + Buffer.BlockCopy(salt, 0, material, secretBytes.Length + shareBytes.Length, salt.Length); + return SHA256.HashData(material); + } +} diff --git a/MareSynchronos/Services/Mediator/MareMediator.cs b/MareSynchronos/Services/Mediator/MareMediator.cs index 99f66ae..e35533b 100644 --- a/MareSynchronos/Services/Mediator/MareMediator.cs +++ b/MareSynchronos/Services/Mediator/MareMediator.cs @@ -109,9 +109,23 @@ public sealed class MareMediator : IHostedService, IDisposable } } + private bool _disposed; + public void Dispose() { - _loopCts.Cancel(); + if (_disposed) return; + _disposed = true; + if (!_loopCts.IsCancellationRequested) + { + try + { + _loopCts.Cancel(); + } + catch (ObjectDisposedException) + { + // already disposed, swallow + } + } _loopCts.Dispose(); } diff --git a/MareSynchronos/Services/Mediator/Messages.cs b/MareSynchronos/Services/Mediator/Messages.cs index 09e01fb..b921865 100644 --- a/MareSynchronos/Services/Mediator/Messages.cs +++ b/MareSynchronos/Services/Mediator/Messages.cs @@ -115,12 +115,15 @@ 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 SyncshellDiscoveryUpdated(List Entries) : MessageBase; +public record SyncshellAutoDetectStateChanged(string Gid, bool Visible, bool PasswordTemporarilyDisabled) : 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; public record PairSyncOverrideChanged(string Uid, bool? DisableSounds, bool? DisableAnimations, bool? DisableVfx) : MessageBase; public record GroupSyncOverrideChanged(string Gid, bool? DisableSounds, bool? DisableAnimations, bool? DisableVfx) : MessageBase; +public record NotificationStateChanged(int TotalCount) : MessageBase; public record PluginChangeMessage(string InternalName, Version Version, bool IsLoaded) : KeyedMessage(InternalName); #pragma warning restore S2094 diff --git a/MareSynchronos/Services/Notification/NotificationTracker.cs b/MareSynchronos/Services/Notification/NotificationTracker.cs new file mode 100644 index 0000000..ce1c6a9 --- /dev/null +++ b/MareSynchronos/Services/Notification/NotificationTracker.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MareSynchronos.Services.Mediator; + +namespace MareSynchronos.Services.Notifications; + +public enum NotificationCategory +{ + AutoDetect, +} + +public sealed record NotificationEntry(NotificationCategory Category, string Id, string Title, string? Description, DateTime CreatedAt) +{ + public static NotificationEntry AutoDetect(string uid, string displayName) + => new(NotificationCategory.AutoDetect, uid, displayName, "Nouvelle demande d'appairage via AutoDetect.", DateTime.UtcNow); +} + +public sealed class NotificationTracker +{ + private readonly MareMediator _mediator; + private readonly Dictionary<(NotificationCategory Category, string Id), NotificationEntry> _entries = new(); + private readonly object _lock = new(); + + public NotificationTracker(MareMediator mediator) + { + _mediator = mediator; + } + + public void Upsert(NotificationEntry entry) + { + lock (_lock) + { + _entries[(entry.Category, entry.Id)] = entry; + } + PublishState(); + } + + public void Remove(NotificationCategory category, string id) + { + lock (_lock) + { + _entries.Remove((category, id)); + } + PublishState(); + } + + public IReadOnlyList GetEntries() + { + lock (_lock) + { + return _entries.Values + .OrderBy(e => e.CreatedAt) + .ToList(); + } + } + + public int Count + { + get + { + lock (_lock) + { + return _entries.Count; + } + } + } + + private void PublishState() + { + _mediator.Publish(new NotificationStateChanged(Count)); + } +} diff --git a/MareSynchronos/Services/UiFactory.cs b/MareSynchronos/Services/UiFactory.cs index 0d833be..e2364b7 100644 --- a/MareSynchronos/Services/UiFactory.cs +++ b/MareSynchronos/Services/UiFactory.cs @@ -1,5 +1,6 @@ using MareSynchronos.API.Dto.Group; using MareSynchronos.PlayerData.Pairs; +using MareSynchronos.Services.AutoDetect; using MareSynchronos.Services.Mediator; using MareSynchronos.Services.ServerConfiguration; using MareSynchronos.UI; @@ -19,9 +20,10 @@ public class UiFactory private readonly ServerConfigurationManager _serverConfigManager; private readonly MareProfileManager _mareProfileManager; private readonly PerformanceCollectorService _performanceCollectorService; + private readonly SyncshellDiscoveryService _syncshellDiscoveryService; public UiFactory(ILoggerFactory loggerFactory, MareMediator mareMediator, ApiController apiController, - UiSharedService uiSharedService, PairManager pairManager, ServerConfigurationManager serverConfigManager, + UiSharedService uiSharedService, PairManager pairManager, SyncshellDiscoveryService syncshellDiscoveryService, ServerConfigurationManager serverConfigManager, MareProfileManager mareProfileManager, PerformanceCollectorService performanceCollectorService) { _loggerFactory = loggerFactory; @@ -29,6 +31,7 @@ public class UiFactory _apiController = apiController; _uiSharedService = uiSharedService; _pairManager = pairManager; + _syncshellDiscoveryService = syncshellDiscoveryService; _serverConfigManager = serverConfigManager; _mareProfileManager = mareProfileManager; _performanceCollectorService = performanceCollectorService; @@ -37,7 +40,7 @@ public class UiFactory public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto) { return new SyncshellAdminUI(_loggerFactory.CreateLogger(), _mareMediator, - _apiController, _uiSharedService, _pairManager, dto, _performanceCollectorService); + _apiController, _uiSharedService, _pairManager, _syncshellDiscoveryService, dto, _performanceCollectorService); } public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair) diff --git a/MareSynchronos/UI/AutoDetectUi.cs b/MareSynchronos/UI/AutoDetectUi.cs index e926e96..fce9b4a 100644 --- a/MareSynchronos/UI/AutoDetectUi.cs +++ b/MareSynchronos/UI/AutoDetectUi.cs @@ -1,11 +1,13 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Dalamud.Bindings.ImGui; using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; +using MareSynchronos.API.Dto.Group; using MareSynchronos.MareConfiguration; using MareSynchronos.PlayerData.Pairs; using MareSynchronos.Services.Mediator; @@ -29,11 +31,16 @@ public class AutoDetectUi : WindowMediatorSubscriberBase private readonly PairManager _pairManager; private List _entries; private readonly HashSet _acceptInFlight = new(StringComparer.Ordinal); + private readonly SyncshellDiscoveryService _syncshellDiscoveryService; + private List _syncshellEntries = []; + private bool _syncshellInitialized; + private readonly HashSet _syncshellJoinInFlight = new(StringComparer.OrdinalIgnoreCase); + private string? _syncshellLastError; public AutoDetectUi(ILogger logger, MareMediator mediator, MareConfigService configService, DalamudUtilService dalamudUtilService, AutoDetectRequestService requestService, NearbyPendingService pendingService, PairManager pairManager, - NearbyDiscoveryService discoveryService, + NearbyDiscoveryService discoveryService, SyncshellDiscoveryService syncshellDiscoveryService, PerformanceCollectorService performanceCollectorService) : base(logger, mediator, "AutoDetect", performanceCollectorService) { @@ -43,7 +50,9 @@ public class AutoDetectUi : WindowMediatorSubscriberBase _pendingService = pendingService; _pairManager = pairManager; _discoveryService = discoveryService; + _syncshellDiscoveryService = syncshellDiscoveryService; Mediator.Subscribe(this, OnDiscoveryUpdated); + Mediator.Subscribe(this, OnSyncshellDiscoveryUpdated); _entries = _discoveryService.SnapshotEntries(); Flags |= ImGuiWindowFlags.NoScrollbar; @@ -81,14 +90,7 @@ public class AutoDetectUi : WindowMediatorSubscriberBase }); DrawStyledTab("Proximité", accent, inactiveTab, hoverTab, DrawNearbyTab); - - using (ImRaii.Disabled(true)) - { - DrawStyledTab("Syncshell", accent, inactiveTab, hoverTab, () => - { - UiSharedService.ColorTextWrapped("Disponible prochainement.", ImGuiColors.DalamudGrey3); - }, true); - } + DrawStyledTab("Syncshell", accent, inactiveTab, hoverTab, DrawSyncshellTab); } public void DrawInline() @@ -221,6 +223,7 @@ public class AutoDetectUi : WindowMediatorSubscriberBase var sourceEntries = _entries.Count > 0 ? _entries : _discoveryService.SnapshotEntries(); var orderedEntries = sourceEntries + .Where(e => e.IsMatch) .OrderBy(e => float.IsNaN(e.Distance) ? float.MaxValue : e.Distance) .ToList(); @@ -245,10 +248,9 @@ public class AutoDetectUi : WindowMediatorSubscriberBase for (int i = 0; i < orderedEntries.Count; i++) { var entry = orderedEntries[i]; - bool isMatch = entry.IsMatch; bool alreadyPaired = IsAlreadyPairedByUidOrAlias(entry); bool overDistance = !float.IsNaN(entry.Distance) && entry.Distance > maxDist; - bool canRequest = isMatch && entry.AcceptPairRequests && !string.IsNullOrEmpty(entry.Token) && !alreadyPaired; + bool canRequest = entry.AcceptPairRequests && !string.IsNullOrEmpty(entry.Token) && !alreadyPaired; string displayName = entry.DisplayName ?? entry.Name; string worldName = entry.WorldId == 0 @@ -260,13 +262,11 @@ public class AutoDetectUi : WindowMediatorSubscriberBase ? "Déjà appairé" : overDistance ? $"Hors portée (> {maxDist} m)" - : !isMatch - ? "Umbra non activé" - : !entry.AcceptPairRequests - ? "Invitations refusées" - : string.IsNullOrEmpty(entry.Token) - ? "Indisponible" - : "Disponible"; + : !entry.AcceptPairRequests + ? "Invitations refusées" + : string.IsNullOrEmpty(entry.Token) + ? "Indisponible" + : "Disponible"; ImGui.TableNextColumn(); ImGui.TextUnformatted(displayName); @@ -297,13 +297,11 @@ public class AutoDetectUi : WindowMediatorSubscriberBase ? "Vous êtes déjà appairé avec ce joueur." : overDistance ? $"Ce joueur est au-delà de la distance maximale configurée ({maxDist} m)." - : !isMatch - ? "Ce joueur n'utilise pas UmbraSync ou ne s'est pas rendu détectable." - : !entry.AcceptPairRequests - ? "Ce joueur a désactivé la réception automatique des invitations." - : string.IsNullOrEmpty(entry.Token) - ? "Impossible d'obtenir un jeton d'invitation pour ce joueur." - : string.Empty; + : !entry.AcceptPairRequests + ? "Ce joueur a désactivé la réception automatique des invitations." + : string.IsNullOrEmpty(entry.Token) + ? "Impossible d'obtenir un jeton d'invitation pour ce joueur." + : string.Empty; ImGui.TextDisabled(status); if (!string.IsNullOrEmpty(reason)) @@ -317,11 +315,147 @@ public class AutoDetectUi : WindowMediatorSubscriberBase ImGui.EndTable(); } + private async Task JoinSyncshellAsync(SyncshellDiscoveryEntryDto entry) + { + if (!_syncshellJoinInFlight.Add(entry.GID)) + { + return; + } + + try + { + var joined = await _syncshellDiscoveryService.JoinAsync(entry.GID, CancellationToken.None).ConfigureAwait(false); + if (joined) + { + Mediator.Publish(new NotificationMessage("AutoDetect Syncshell", $"Rejoint {entry.Alias ?? entry.GID}.", NotificationType.Info, TimeSpan.FromSeconds(5))); + await _syncshellDiscoveryService.RefreshAsync(CancellationToken.None).ConfigureAwait(false); + } + else + { + _syncshellLastError = $"Impossible de rejoindre {entry.Alias ?? entry.GID}."; + Mediator.Publish(new NotificationMessage("AutoDetect Syncshell", _syncshellLastError, NotificationType.Warning, TimeSpan.FromSeconds(5))); + } + } + catch (Exception ex) + { + _syncshellLastError = $"Erreur lors de l'adhésion : {ex.Message}"; + Mediator.Publish(new NotificationMessage("AutoDetect Syncshell", _syncshellLastError, NotificationType.Error, TimeSpan.FromSeconds(5))); + } + finally + { + _syncshellJoinInFlight.Remove(entry.GID); + } + } + + private void DrawSyncshellTab() + { + if (!_syncshellInitialized) + { + _syncshellInitialized = true; + _ = _syncshellDiscoveryService.RefreshAsync(CancellationToken.None); + } + + bool isRefreshing = _syncshellDiscoveryService.IsRefreshing; + var serviceError = _syncshellDiscoveryService.LastError; + + if (ImGui.Button("Actualiser la liste")) + { + _ = _syncshellDiscoveryService.RefreshAsync(CancellationToken.None); + } + UiSharedService.AttachToolTip("Met à jour la liste des Syncshells ayant activé l'AutoDetect."); + + if (isRefreshing) + { + ImGui.SameLine(); + ImGui.TextDisabled("Actualisation..."); + } + + ImGuiHelpers.ScaledDummy(4); + UiSharedService.TextWrapped("Les Syncshells affichées ont temporairement désactivé leur mot de passe pour permettre un accès direct via AutoDetect. Rejoignez-les uniquement si vous faites confiance aux administrateurs."); + + if (!string.IsNullOrEmpty(serviceError)) + { + UiSharedService.ColorTextWrapped(serviceError, ImGuiColors.DalamudRed); + } + else if (!string.IsNullOrEmpty(_syncshellLastError)) + { + UiSharedService.ColorTextWrapped(_syncshellLastError!, ImGuiColors.DalamudOrange); + } + + var entries = _syncshellEntries.Count > 0 ? _syncshellEntries : _syncshellDiscoveryService.Entries.ToList(); + if (entries.Count == 0) + { + ImGuiHelpers.ScaledDummy(4); + UiSharedService.ColorTextWrapped("Aucune Syncshell n'est actuellement visible dans AutoDetect.", ImGuiColors.DalamudGrey3); + return; + } + + if (!ImGui.BeginTable("autodetect-syncshells", 5, ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.RowBg)) + { + return; + } + + ImGui.TableSetupColumn("Nom"); + ImGui.TableSetupColumn("Propriétaire"); + ImGui.TableSetupColumn("Membres"); + ImGui.TableSetupColumn("Invitations"); + ImGui.TableSetupColumn("Action"); + ImGui.TableHeadersRow(); + + foreach (var entry in entries.OrderBy(e => e.Alias ?? e.GID, StringComparer.OrdinalIgnoreCase)) + { + bool alreadyMember = _pairManager.Groups.Keys.Any(g => string.Equals(g.GID, entry.GID, StringComparison.OrdinalIgnoreCase)); + bool joining = _syncshellJoinInFlight.Contains(entry.GID); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(string.IsNullOrEmpty(entry.Alias) ? entry.GID : $"{entry.Alias} ({entry.GID})"); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(string.IsNullOrEmpty(entry.OwnerAlias) ? entry.OwnerUID : $"{entry.OwnerAlias} ({entry.OwnerUID})"); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(entry.MemberCount.ToString(CultureInfo.InvariantCulture)); + + ImGui.TableNextColumn(); + string inviteMode = entry.AutoAcceptPairs ? "Auto" : "Manuel"; + ImGui.TextUnformatted(inviteMode); + if (!entry.AutoAcceptPairs) + { + UiSharedService.AttachToolTip("L'administrateur doit approuver manuellement les nouveaux membres."); + } + + ImGui.TableNextColumn(); + using (ImRaii.Disabled(alreadyMember || joining)) + { + if (alreadyMember) + { + ImGui.TextDisabled("Déjà membre"); + } + else if (joining) + { + ImGui.TextDisabled("Connexion..."); + } + else if (ImGui.Button("Rejoindre")) + { + _syncshellLastError = null; + _ = JoinSyncshellAsync(entry); + } + } + } + + ImGui.EndTable(); + } + private void OnDiscoveryUpdated(Services.Mediator.DiscoveryListUpdated msg) { _entries = msg.Entries; } + private void OnSyncshellDiscoveryUpdated(SyncshellDiscoveryUpdated msg) + { + _syncshellEntries = msg.Entries; + } + private bool IsAlreadyPairedByUidOrAlias(Services.Mediator.NearbyEntry e) { try diff --git a/MareSynchronos/UI/CharaDataHubUi.Functions.cs b/MareSynchronos/UI/CharaDataHubUi.Functions.cs index 3f2c809..c4fcdf1 100644 --- a/MareSynchronos/UI/CharaDataHubUi.Functions.cs +++ b/MareSynchronos/UI/CharaDataHubUi.Functions.cs @@ -6,7 +6,7 @@ using System.Text; namespace MareSynchronos.UI; -internal sealed partial class CharaDataHubUi +public sealed partial class CharaDataHubUi { private static string GetAccessTypeString(AccessTypeDto dto) => dto switch { diff --git a/MareSynchronos/UI/CharaDataHubUi.GposeTogether.cs b/MareSynchronos/UI/CharaDataHubUi.GposeTogether.cs index 4b89aa5..28231a3 100644 --- a/MareSynchronos/UI/CharaDataHubUi.GposeTogether.cs +++ b/MareSynchronos/UI/CharaDataHubUi.GposeTogether.cs @@ -7,7 +7,7 @@ using MareSynchronos.Services.CharaData.Models; namespace MareSynchronos.UI; -internal sealed partial class CharaDataHubUi +public sealed partial class CharaDataHubUi { private string _joinLobbyId = string.Empty; private void DrawGposeTogether() @@ -90,7 +90,7 @@ internal sealed partial class CharaDataHubUi if (!_uiSharedService.IsInGpose) { ImGuiHelpers.ScaledDummy(5); - UiSharedService.DrawGroupedCenteredColorText("Assigning users to characters is only available in GPose.", ImGuiColors.DalamudYellow, 300); + UiSharedService.DrawGroupedCenteredColorText("Assigning users to characters is only available in GPose.", UiSharedService.AccentColor, 300); } UiSharedService.DistanceSeparator(); ImGui.TextUnformatted("Users In Lobby"); @@ -104,7 +104,7 @@ internal sealed partial class CharaDataHubUi if (!_charaDataGposeTogetherManager.UsersInLobby.Any() && !string.IsNullOrEmpty(_charaDataGposeTogetherManager.CurrentGPoseLobbyId)) { - UiSharedService.DrawGroupedCenteredColorText("No other users in current GPose lobby", ImGuiColors.DalamudYellow); + UiSharedService.DrawGroupedCenteredColorText("No other users in current GPose lobby", UiSharedService.AccentColor); } else { diff --git a/MareSynchronos/UI/CharaDataHubUi.McdOnline.cs b/MareSynchronos/UI/CharaDataHubUi.McdOnline.cs index f4daa7c..8572bbc 100644 --- a/MareSynchronos/UI/CharaDataHubUi.McdOnline.cs +++ b/MareSynchronos/UI/CharaDataHubUi.McdOnline.cs @@ -9,7 +9,7 @@ using System.Numerics; namespace MareSynchronos.UI; -internal sealed partial class CharaDataHubUi +public sealed partial class CharaDataHubUi { private void DrawEditCharaData(CharaDataFullExtendedDto? dataDto) { @@ -18,7 +18,7 @@ internal sealed partial class CharaDataHubUi if (dataDto == null) { ImGuiHelpers.ScaledDummy(5); - UiSharedService.DrawGroupedCenteredColorText("Select an entry above to edit its data.", ImGuiColors.DalamudYellow); + UiSharedService.DrawGroupedCenteredColorText("Select an entry above to edit its data.", UiSharedService.AccentColor); return; } @@ -26,7 +26,7 @@ internal sealed partial class CharaDataHubUi if (updateDto == null) { - UiSharedService.DrawGroupedCenteredColorText("Something went awfully wrong and there's no update DTO. Try updating Character Data via the button above.", ImGuiColors.DalamudYellow); + UiSharedService.DrawGroupedCenteredColorText("Something went awfully wrong and there's no update DTO. Try updating Character Data via the button above.", UiSharedService.AccentColor); return; } @@ -61,7 +61,7 @@ internal sealed partial class CharaDataHubUi } if (_charaDataManager.CharaUpdateTask != null && !_charaDataManager.CharaUpdateTask.IsCompleted) { - UiSharedService.ColorTextWrapped("Updating data on server, please wait.", ImGuiColors.DalamudYellow); + UiSharedService.ColorTextWrapped("Updating data on server, please wait.", UiSharedService.AccentColor); } } @@ -71,7 +71,7 @@ internal sealed partial class CharaDataHubUi { if (_charaDataManager.UploadProgress != null) { - UiSharedService.ColorTextWrapped(_charaDataManager.UploadProgress.Value ?? string.Empty, ImGuiColors.DalamudYellow); + UiSharedService.ColorTextWrapped(_charaDataManager.UploadProgress.Value ?? string.Empty, UiSharedService.AccentColor); } if ((!_charaDataManager.UploadTask?.IsCompleted ?? false) && _uiSharedService.IconTextButton(FontAwesomeIcon.Ban, "Cancel Upload")) { @@ -230,7 +230,7 @@ internal sealed partial class CharaDataHubUi ImGui.SameLine(); ImGuiHelpers.ScaledDummy(20, 1); ImGui.SameLine(); - UiSharedService.ColorTextWrapped("New data was set. It may contain files that require to be uploaded (will happen on Saving to server)", ImGuiColors.DalamudYellow); + UiSharedService.ColorTextWrapped("New data was set. It may contain files that require to be uploaded (will happen on Saving to server)", UiSharedService.AccentColor); } ImGui.TextUnformatted("Contains Manipulation Data"); @@ -385,7 +385,7 @@ internal sealed partial class CharaDataHubUi } } ImGui.SameLine(); - using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudYellow, poseCount == maxPoses)) + using (ImRaii.PushColor(ImGuiCol.Text, UiSharedService.AccentColor, poseCount == maxPoses)) ImGui.TextUnformatted($"{poseCount}/{maxPoses} poses attached"); ImGuiHelpers.ScaledDummy(5); @@ -395,7 +395,7 @@ internal sealed partial class CharaDataHubUi if (!_uiSharedService.IsInGpose && _charaDataManager.BrioAvailable) { ImGuiHelpers.ScaledDummy(5); - UiSharedService.DrawGroupedCenteredColorText("To attach pose and world data you need to be in GPose.", ImGuiColors.DalamudYellow); + UiSharedService.DrawGroupedCenteredColorText("To attach pose and world data you need to be in GPose.", UiSharedService.AccentColor); ImGuiHelpers.ScaledDummy(5); } else if (!_charaDataManager.BrioAvailable) @@ -414,7 +414,7 @@ internal sealed partial class CharaDataHubUi if (pose.Id == null) { ImGui.SameLine(50); - _uiSharedService.IconText(FontAwesomeIcon.Plus, ImGuiColors.DalamudYellow); + _uiSharedService.IconText(FontAwesomeIcon.Plus, UiSharedService.AccentColor); UiSharedService.AttachToolTip("This pose has not been added to the server yet. Save changes to upload this Pose data."); } @@ -422,14 +422,14 @@ internal sealed partial class CharaDataHubUi if (poseHasChanges) { ImGui.SameLine(50); - _uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, ImGuiColors.DalamudYellow); + _uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UiSharedService.AccentColor); UiSharedService.AttachToolTip("This pose has changes that have not been saved to the server yet."); } ImGui.SameLine(75); if (pose.Description == null && pose.WorldData == null && pose.PoseData == null) { - UiSharedService.ColorText("Pose scheduled for deletion", ImGuiColors.DalamudYellow); + UiSharedService.ColorText("Pose scheduled for deletion", UiSharedService.AccentColor); } else { @@ -586,7 +586,7 @@ internal sealed partial class CharaDataHubUi var idText = entry.FullId; if (uDto?.HasChanges ?? false) { - UiSharedService.ColorText(idText, ImGuiColors.DalamudYellow); + UiSharedService.ColorText(idText, UiSharedService.AccentColor); UiSharedService.AttachToolTip("This entry has unsaved changes"); } else @@ -641,7 +641,7 @@ internal sealed partial class CharaDataHubUi FontAwesomeIcon eIcon = FontAwesomeIcon.None; if (!Equals(DateTime.MaxValue, entry.ExpiryDate)) eIcon = FontAwesomeIcon.Clock; - _uiSharedService.IconText(eIcon, ImGuiColors.DalamudYellow); + _uiSharedService.IconText(eIcon, UiSharedService.AccentColor); if (ImGui.IsItemClicked()) SelectedDtoId = entry.Id; if (eIcon != FontAwesomeIcon.None) { @@ -677,13 +677,13 @@ internal sealed partial class CharaDataHubUi if (_charaDataManager.OwnCharaData.Count == _charaDataManager.MaxCreatableCharaData) { ImGui.AlignTextToFramePadding(); - UiSharedService.ColorTextWrapped("You have reached the maximum Character Data entries and cannot create more.", ImGuiColors.DalamudYellow); + UiSharedService.ColorTextWrapped("You have reached the maximum Character Data entries and cannot create more.", UiSharedService.AccentColor); } } if (_charaDataManager.DataCreationTask != null && !_charaDataManager.DataCreationTask.IsCompleted) { - UiSharedService.ColorTextWrapped("Creating new character data entry on server...", ImGuiColors.DalamudYellow); + UiSharedService.ColorTextWrapped("Creating new character data entry on server...", UiSharedService.AccentColor); } else if (_charaDataManager.DataCreationTask != null && _charaDataManager.DataCreationTask.IsCompleted) { diff --git a/MareSynchronos/UI/CharaDataHubUi.NearbyPoses.cs b/MareSynchronos/UI/CharaDataHubUi.NearbyPoses.cs index e27b0f4..4c5fdb8 100644 --- a/MareSynchronos/UI/CharaDataHubUi.NearbyPoses.cs +++ b/MareSynchronos/UI/CharaDataHubUi.NearbyPoses.cs @@ -7,7 +7,7 @@ using System.Numerics; namespace MareSynchronos.UI; -internal partial class CharaDataHubUi +public sealed partial class CharaDataHubUi { private void DrawNearbyPoses() { @@ -86,7 +86,7 @@ internal partial class CharaDataHubUi if (!_uiSharedService.IsInGpose) { ImGuiHelpers.ScaledDummy(5); - UiSharedService.DrawGroupedCenteredColorText("Spawning and applying pose data is only available in GPose.", ImGuiColors.DalamudYellow); + UiSharedService.DrawGroupedCenteredColorText("Spawning and applying pose data is only available in GPose.", UiSharedService.AccentColor); ImGuiHelpers.ScaledDummy(5); } @@ -101,7 +101,7 @@ internal partial class CharaDataHubUi using var indent = ImRaii.PushIndent(5f); if (_charaDataNearbyManager.NearbyData.Count == 0) { - UiSharedService.DrawGroupedCenteredColorText("No Shared World Poses found nearby.", ImGuiColors.DalamudYellow); + UiSharedService.DrawGroupedCenteredColorText("No Shared World Poses found nearby.", UiSharedService.AccentColor); } bool wasAnythingHovered = false; diff --git a/MareSynchronos/UI/CharaDataHubUi.cs b/MareSynchronos/UI/CharaDataHubUi.cs index 2680555..7b45af5 100644 --- a/MareSynchronos/UI/CharaDataHubUi.cs +++ b/MareSynchronos/UI/CharaDataHubUi.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Colors; @@ -16,10 +17,13 @@ using MareSynchronos.Services.Mediator; using MareSynchronos.Services.ServerConfiguration; using MareSynchronos.Utils; using Microsoft.Extensions.Logging; +using System.IO; +using System.Linq; +using System.Threading; namespace MareSynchronos.UI; -internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase +public sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase { private const int maxPoses = 10; private readonly CharaDataManager _charaDataManager; @@ -31,6 +35,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase private readonly CharaDataGposeTogetherManager _charaDataGposeTogetherManager; private readonly ServerConfigurationManager _serverConfigurationManager; private readonly UiSharedService _uiSharedService; + private readonly McdfShareManager _mcdfShareManager; private CancellationTokenSource? _closalCts = new(); private bool _disableUI = false; private CancellationTokenSource? _disposalCts = new(); @@ -63,6 +68,15 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase } } + + private static string SanitizeFileName(string? candidate, string fallback) + { + var invalidChars = Path.GetInvalidFileNameChars(); + if (string.IsNullOrWhiteSpace(candidate)) return fallback; + + var sanitized = new string(candidate.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray()).Trim('_'); + return string.IsNullOrWhiteSpace(sanitized) ? fallback : sanitized; + } private string _selectedSpecificUserIndividual = string.Empty; private string _selectedSpecificGroupIndividual = string.Empty; private string _sharedWithYouDescriptionFilter = string.Empty; @@ -74,12 +88,21 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase private string? _openComboHybridId = null; private (string Id, string? Alias, string AliasOrId, string? Note)[]? _openComboHybridEntries = null; private bool _comboHybridUsedLastFrame = false; + private bool _mcdfShareInitialized; + private string _mcdfShareDescription = string.Empty; + private readonly List _mcdfShareAllowedIndividuals = new(); + private readonly List _mcdfShareAllowedSyncshells = new(); + private string _mcdfShareIndividualDropdownSelection = string.Empty; + private string _mcdfShareIndividualInput = string.Empty; + private string _mcdfShareSyncshellDropdownSelection = string.Empty; + private string _mcdfShareSyncshellInput = string.Empty; + private int _mcdfShareExpireDays; public CharaDataHubUi(ILogger logger, MareMediator mediator, PerformanceCollectorService performanceCollectorService, CharaDataManager charaDataManager, CharaDataNearbyManager charaDataNearbyManager, CharaDataConfigService configService, UiSharedService uiSharedService, ServerConfigurationManager serverConfigurationManager, DalamudUtilService dalamudUtilService, FileDialogManager fileDialogManager, PairManager pairManager, - CharaDataGposeTogetherManager charaDataGposeTogetherManager) + CharaDataGposeTogetherManager charaDataGposeTogetherManager, McdfShareManager mcdfShareManager) : base(logger, mediator, "Umbra Character Data Hub###UmbraCharaDataUI", performanceCollectorService) { SetWindowSizeConstraints(); @@ -93,6 +116,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase _fileDialogManager = fileDialogManager; _pairManager = pairManager; _charaDataGposeTogetherManager = charaDataGposeTogetherManager; + _mcdfShareManager = mcdfShareManager; Mediator.Subscribe(this, (_) => IsOpen |= _configService.Current.OpenMareHubOnGposeStart); Mediator.Subscribe(this, (msg) => { @@ -158,6 +182,19 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase } protected override void DrawInternal() + { + DrawHubContent(); + } + + public void DrawInline() + { + using (ImRaii.PushId("CharaDataHubInline")) + { + DrawHubContent(); + } + } + + private void DrawHubContent() { if (!_comboHybridUsedLastFrame) { @@ -198,7 +235,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase } if (!string.IsNullOrEmpty(_charaDataManager.DataApplicationProgress)) { - UiSharedService.ColorTextWrapped(_charaDataManager.DataApplicationProgress, ImGuiColors.DalamudYellow); + UiSharedService.ColorTextWrapped(_charaDataManager.DataApplicationProgress, UiSharedService.AccentColor); } if (_charaDataManager.DataApplicationTask != null) { @@ -208,115 +245,140 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase } }); - using var tabs = ImRaii.TabBar("TabsTopLevel"); bool smallUi = false; - - _isHandlingSelf = _charaDataManager.HandledCharaData.Any(c => c.Value.IsSelf); - if (_isHandlingSelf) _openMcdOnlineOnNextRun = false; - - using (var gposeTogetherTabItem = ImRaii.TabItem("GPose Together")) + using (var topTabColor = ImRaii.PushColor(ImGuiCol.Tab, UiSharedService.AccentColor)) + using (var topTabHoverColor = ImRaii.PushColor(ImGuiCol.TabHovered, UiSharedService.AccentHoverColor)) + using (var topTabActiveColor = ImRaii.PushColor(ImGuiCol.TabActive, UiSharedService.AccentActiveColor)) { - if (gposeTogetherTabItem) + using var tabs = ImRaii.TabBar("TabsTopLevel"); + + _isHandlingSelf = _charaDataManager.HandledCharaData.Any(c => c.Value.IsSelf); + if (_isHandlingSelf) _openMcdOnlineOnNextRun = false; + + using (var gposeTogetherTabItem = ImRaii.TabItem("GPose Together")) { - smallUi = true; - - DrawGposeTogether(); - } - } - - using (var applicationTabItem = ImRaii.TabItem("Data Application", _openDataApplicationShared ? ImGuiTabItemFlags.SetSelected : ImGuiTabItemFlags.None)) - { - if (applicationTabItem) - { - smallUi = true; - using var appTabs = ImRaii.TabBar("TabsApplicationLevel"); - - using (ImRaii.Disabled(!_uiSharedService.IsInGpose)) + if (gposeTogetherTabItem) { - using (var gposeTabItem = ImRaii.TabItem("GPose Actors")) + smallUi = true; + + DrawGposeTogether(); + } + } + + using (var applicationTabItem = ImRaii.TabItem("Data Application", _openDataApplicationShared ? ImGuiTabItemFlags.SetSelected : ImGuiTabItemFlags.None)) + { + if (applicationTabItem) + { + smallUi = true; + using (var appTabColor = ImRaii.PushColor(ImGuiCol.Tab, UiSharedService.AccentColor)) + using (var appTabHoverColor = ImRaii.PushColor(ImGuiCol.TabHovered, UiSharedService.AccentHoverColor)) + using (var appTabActiveColor = ImRaii.PushColor(ImGuiCol.TabActive, UiSharedService.AccentActiveColor)) { - if (gposeTabItem) + using var appTabs = ImRaii.TabBar("TabsApplicationLevel"); + + using (ImRaii.Disabled(!_uiSharedService.IsInGpose)) { - using var id = ImRaii.PushId("gposeControls"); - DrawGposeControls(); + using (var gposeTabItem = ImRaii.TabItem("GPose Actors")) + { + if (gposeTabItem) + { + using var id = ImRaii.PushId("gposeControls"); + DrawGposeControls(); + } + } + } + if (!_uiSharedService.IsInGpose) + UiSharedService.AttachToolTip("Only available in GPose"); + + using (var nearbyPosesTabItem = ImRaii.TabItem("Poses Nearby")) + { + if (nearbyPosesTabItem) + { + using var id = ImRaii.PushId("nearbyPoseControls"); + _charaDataNearbyManager.ComputeNearbyData = true; + + DrawNearbyPoses(); + } + else + { + _charaDataNearbyManager.ComputeNearbyData = false; + } + } + + using (var gposeTabItem = ImRaii.TabItem("Apply Data", _openDataApplicationShared ? ImGuiTabItemFlags.SetSelected : ImGuiTabItemFlags.None)) + { + if (gposeTabItem) + { + smallUi |= true; + using var id = ImRaii.PushId("applyData"); + DrawDataApplication(); + } } } } - if (!_uiSharedService.IsInGpose) - UiSharedService.AttachToolTip("Only available in GPose"); - - using (var nearbyPosesTabItem = ImRaii.TabItem("Poses Nearby")) + else { - if (nearbyPosesTabItem) - { - using var id = ImRaii.PushId("nearbyPoseControls"); - _charaDataNearbyManager.ComputeNearbyData = true; - - DrawNearbyPoses(); - } - else - { - _charaDataNearbyManager.ComputeNearbyData = false; - } - } - - using (var gposeTabItem = ImRaii.TabItem("Apply Data", _openDataApplicationShared ? ImGuiTabItemFlags.SetSelected : ImGuiTabItemFlags.None)) - { - if (gposeTabItem) - { - smallUi |= true; - using var id = ImRaii.PushId("applyData"); - DrawDataApplication(); - } + _charaDataNearbyManager.ComputeNearbyData = false; } } - else - { - _charaDataNearbyManager.ComputeNearbyData = false; - } - } - using (ImRaii.Disabled(_isHandlingSelf)) - { - ImGuiTabItemFlags flagsTopLevel = ImGuiTabItemFlags.None; - if (_openMcdOnlineOnNextRun) + using (ImRaii.Disabled(_isHandlingSelf)) { - flagsTopLevel = ImGuiTabItemFlags.SetSelected; - _openMcdOnlineOnNextRun = false; - } - - using (var creationTabItem = ImRaii.TabItem("Data Creation", flagsTopLevel)) - { - if (creationTabItem) + ImGuiTabItemFlags flagsTopLevel = ImGuiTabItemFlags.None; + if (_openMcdOnlineOnNextRun) { - using var creationTabs = ImRaii.TabBar("TabsCreationLevel"); + flagsTopLevel = ImGuiTabItemFlags.SetSelected; + _openMcdOnlineOnNextRun = false; + } - ImGuiTabItemFlags flags = ImGuiTabItemFlags.None; - if (_openMcdOnlineOnNextRun) + using (var creationTabItem = ImRaii.TabItem("Data Creation", flagsTopLevel)) + { + if (creationTabItem) { - flags = ImGuiTabItemFlags.SetSelected; - _openMcdOnlineOnNextRun = false; - } - using (var mcdOnlineTabItem = ImRaii.TabItem("Online Data", flags)) - { - if (mcdOnlineTabItem) + using (var creationTabColor = ImRaii.PushColor(ImGuiCol.Tab, UiSharedService.AccentColor)) + using (var creationTabHoverColor = ImRaii.PushColor(ImGuiCol.TabHovered, UiSharedService.AccentHoverColor)) + using (var creationTabActiveColor = ImRaii.PushColor(ImGuiCol.TabActive, UiSharedService.AccentActiveColor)) { - using var id = ImRaii.PushId("mcdOnline"); - DrawMcdOnline(); - } - } + using var creationTabs = ImRaii.TabBar("TabsCreationLevel"); - using (var mcdfTabItem = ImRaii.TabItem("MCDF Export")) - { - if (mcdfTabItem) - { - using var id = ImRaii.PushId("mcdfExport"); - DrawMcdfExport(); + ImGuiTabItemFlags flags = ImGuiTabItemFlags.None; + if (_openMcdOnlineOnNextRun) + { + flags = ImGuiTabItemFlags.SetSelected; + _openMcdOnlineOnNextRun = false; + } + using (var mcdOnlineTabItem = ImRaii.TabItem("Online Data", flags)) + { + if (mcdOnlineTabItem) + { + using var id = ImRaii.PushId("mcdOnline"); + DrawMcdOnline(); + } + } + + using (var mcdfTabItem = ImRaii.TabItem("MCDF Export")) + { + if (mcdfTabItem) + { + using var id = ImRaii.PushId("mcdfExport"); + DrawMcdfExport(); + } + } + + using (var mcdfShareTabItem = ImRaii.TabItem("Partage MCDF")) + { + if (mcdfShareTabItem) + { + using var id = ImRaii.PushId("mcdfShare"); + DrawMcdfShare(); + } + } } } } } } + if (_isHandlingSelf) { UiSharedService.AttachToolTip("Cannot use creation tools while having Character Data applied to self."); @@ -444,14 +506,18 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase if (!_hasValidGposeTarget) { ImGuiHelpers.ScaledDummy(3); - UiSharedService.DrawGroupedCenteredColorText("Applying data is only available in GPose with a valid selected GPose target.", ImGuiColors.DalamudYellow, 350); + UiSharedService.DrawGroupedCenteredColorText("Applying data is only available in GPose with a valid selected GPose target.", UiSharedService.AccentColor, 350); } ImGuiHelpers.ScaledDummy(10); - using var tabs = ImRaii.TabBar("Tabs"); + using (var applyTabColor = ImRaii.PushColor(ImGuiCol.Tab, UiSharedService.AccentColor)) + using (var applyTabHoverColor = ImRaii.PushColor(ImGuiCol.TabHovered, UiSharedService.AccentHoverColor)) + using (var applyTabActiveColor = ImRaii.PushColor(ImGuiCol.TabActive, UiSharedService.AccentActiveColor)) + { + using var tabs = ImRaii.TabBar("Tabs"); - using (var byFavoriteTabItem = ImRaii.TabItem("Favorites")) + using (var byFavoriteTabItem = ImRaii.TabItem("Favorites")) { if (byFavoriteTabItem) { @@ -603,7 +669,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase if (_configService.Current.FavoriteCodes.Count == 0) { - UiSharedService.ColorTextWrapped("You have no favorites added. Add Favorites through the other tabs before you can use this tab.", ImGuiColors.DalamudYellow); + UiSharedService.ColorTextWrapped("You have no favorites added. Add Favorites through the other tabs before you can use this tab.", UiSharedService.AccentColor); } } } @@ -652,7 +718,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase ImGui.NewLine(); if (!_charaDataManager.DownloadMetaInfoTask?.IsCompleted ?? false) { - UiSharedService.ColorTextWrapped("Downloading meta info. Please wait.", ImGuiColors.DalamudYellow); + UiSharedService.ColorTextWrapped("Downloading meta info. Please wait.", UiSharedService.AccentColor); } if ((_charaDataManager.DownloadMetaInfoTask?.IsCompleted ?? false) && !_charaDataManager.DownloadMetaInfoTask.Result.Success) { @@ -859,15 +925,16 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase UiSharedService.ColorTextWrapped("Failure to read MCDF file. MCDF file is possibly corrupt. Re-export the MCDF file and try again.", UiSharedService.AccentColor); UiSharedService.ColorTextWrapped("Note: if this is your MCDF, try redrawing yourself, wait and re-export the file. " + - "If you received it from someone else have them do the same.", ImGuiColors.DalamudYellow); + "If you received it from someone else have them do the same.", UiSharedService.AccentColor); } } else { - UiSharedService.ColorTextWrapped("Loading Character...", ImGuiColors.DalamudYellow); + UiSharedService.ColorTextWrapped("Loading Character...", UiSharedService.AccentColor); } } } + } } private void DrawMcdfExport() @@ -892,7 +959,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase { string defaultFileName = string.IsNullOrEmpty(_exportDescription) ? "export.mcdf" - : string.Join('_', $"{_exportDescription}.mcdf".Split(Path.GetInvalidFileNameChars())); + : SanitizeFileName(_exportDescription, "export") + ".mcdf"; _uiSharedService.FileDialogManager.SaveFileDialog("Export Character to file", ".mcdf", defaultFileName, ".mcdf", (success, path) => { if (!success) return; @@ -905,12 +972,418 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase }, Directory.Exists(_configService.Current.LastSavedCharaDataLocation) ? _configService.Current.LastSavedCharaDataLocation : null); } UiSharedService.ColorTextWrapped("Note: For best results make sure you have everything you want to be shared as well as the correct character appearance" + - " equipped and redraw your character before exporting.", ImGuiColors.DalamudYellow); + " equipped and redraw your character before exporting.", UiSharedService.AccentColor); ImGui.Unindent(); } } + private void DrawMcdfShare() + { + if (!_mcdfShareInitialized && !_mcdfShareManager.IsBusy) + { + _mcdfShareInitialized = true; + _ = _mcdfShareManager.RefreshAsync(CancellationToken.None); + } + + if (_mcdfShareManager.IsBusy) + { + UiSharedService.ColorTextWrapped("Traitement en cours...", ImGuiColors.DalamudYellow); + } + + if (!string.IsNullOrEmpty(_mcdfShareManager.LastError)) + { + UiSharedService.ColorTextWrapped(_mcdfShareManager.LastError!, ImGuiColors.DalamudRed); + } + else if (!string.IsNullOrEmpty(_mcdfShareManager.LastSuccess)) + { + UiSharedService.ColorTextWrapped(_mcdfShareManager.LastSuccess!, ImGuiColors.HealerGreen); + } + + if (ImGui.Button("Actualiser les partages")) + { + _ = _mcdfShareManager.RefreshAsync(CancellationToken.None); + } + + ImGui.Separator(); + _uiSharedService.BigText("Créer un partage MCDF"); + + ImGui.InputTextWithHint("##mcdfShareDescription", "Description", ref _mcdfShareDescription, 128); + ImGui.InputInt("Expiration (jours, 0 = jamais)", ref _mcdfShareExpireDays); + + DrawMcdfShareIndividualDropdown(); + ImGui.SameLine(); + ImGui.SetNextItemWidth(220f); + if (ImGui.InputTextWithHint("##mcdfShareUidInput", "UID ou vanity", ref _mcdfShareIndividualInput, 32)) + { + _mcdfShareIndividualDropdownSelection = string.Empty; + } + ImGui.SameLine(); + var normalizedUid = NormalizeUidCandidate(_mcdfShareIndividualInput); + using (ImRaii.Disabled(string.IsNullOrEmpty(normalizedUid) + || _mcdfShareAllowedIndividuals.Any(p => string.Equals(p, normalizedUid, StringComparison.OrdinalIgnoreCase)))) + { + if (ImGui.SmallButton("Ajouter")) + { + _mcdfShareAllowedIndividuals.Add(normalizedUid); + _mcdfShareIndividualInput = string.Empty; + _mcdfShareIndividualDropdownSelection = string.Empty; + } + } + ImGui.SameLine(); + ImGui.TextUnformatted("UID synchronisé à ajouter"); + _uiSharedService.DrawHelpText("Choisissez un pair synchronisé dans la liste ou saisissez un UID. Les utilisateurs listés pourront récupérer ce partage MCDF."); + + foreach (var uid in _mcdfShareAllowedIndividuals.ToArray()) + { + using (ImRaii.PushId("mcdfShareUid" + uid)) + { + ImGui.BulletText(FormatPairLabel(uid)); + ImGui.SameLine(); + if (ImGui.SmallButton("Retirer")) + { + _mcdfShareAllowedIndividuals.Remove(uid); + } + } + } + + DrawMcdfShareSyncshellDropdown(); + ImGui.SameLine(); + ImGui.SetNextItemWidth(220f); + if (ImGui.InputTextWithHint("##mcdfShareSyncshellInput", "GID ou alias", ref _mcdfShareSyncshellInput, 32)) + { + _mcdfShareSyncshellDropdownSelection = string.Empty; + } + ImGui.SameLine(); + var normalizedSyncshell = NormalizeSyncshellCandidate(_mcdfShareSyncshellInput); + using (ImRaii.Disabled(string.IsNullOrEmpty(normalizedSyncshell) + || _mcdfShareAllowedSyncshells.Any(p => string.Equals(p, normalizedSyncshell, StringComparison.OrdinalIgnoreCase)))) + { + if (ImGui.SmallButton("Ajouter")) + { + _mcdfShareAllowedSyncshells.Add(normalizedSyncshell); + _mcdfShareSyncshellInput = string.Empty; + _mcdfShareSyncshellDropdownSelection = string.Empty; + } + } + ImGui.SameLine(); + ImGui.TextUnformatted("Syncshell à ajouter"); + _uiSharedService.DrawHelpText("Sélectionnez une syncshell synchronisée ou saisissez un identifiant. Les syncshells listées auront accès au partage."); + + foreach (var shell in _mcdfShareAllowedSyncshells.ToArray()) + { + using (ImRaii.PushId("mcdfShareShell" + shell)) + { + ImGui.BulletText(FormatSyncshellLabel(shell)); + ImGui.SameLine(); + if (ImGui.SmallButton("Retirer")) + { + _mcdfShareAllowedSyncshells.Remove(shell); + } + } + } + + using (ImRaii.Disabled(_mcdfShareManager.IsBusy)) + { + if (ImGui.Button("Créer")) + { + DateTime? expiresAt = _mcdfShareExpireDays <= 0 ? null : DateTime.UtcNow.AddDays(_mcdfShareExpireDays); + _ = _mcdfShareManager.CreateShareAsync(_mcdfShareDescription, _mcdfShareAllowedIndividuals.ToList(), _mcdfShareAllowedSyncshells.ToList(), expiresAt, CancellationToken.None); + _mcdfShareDescription = string.Empty; + _mcdfShareAllowedIndividuals.Clear(); + _mcdfShareAllowedSyncshells.Clear(); + _mcdfShareIndividualInput = string.Empty; + _mcdfShareIndividualDropdownSelection = string.Empty; + _mcdfShareSyncshellInput = string.Empty; + _mcdfShareSyncshellDropdownSelection = string.Empty; + _mcdfShareExpireDays = 0; + } + } + + ImGui.Separator(); + _uiSharedService.BigText("Mes partages : "); + + if (_mcdfShareManager.OwnShares.Count == 0) + { + ImGui.TextDisabled("Aucun partage MCDF créé."); + } + else if (ImGui.BeginTable("mcdf-own-shares", 6, ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersOuter)) + { + ImGui.TableSetupColumn("Description"); + ImGui.TableSetupColumn("Créé le"); + ImGui.TableSetupColumn("Expire"); + ImGui.TableSetupColumn("Téléchargements"); + ImGui.TableSetupColumn("Accès"); + ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.WidthFixed, 220f); + ImGui.TableHeadersRow(); + + foreach (var entry in _mcdfShareManager.OwnShares) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(string.IsNullOrEmpty(entry.Description) ? entry.Id.ToString() : entry.Description); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(entry.CreatedUtc.ToLocalTime().ToString("g")); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(entry.ExpiresAtUtc.HasValue ? entry.ExpiresAtUtc.Value.ToLocalTime().ToString("g") : "Jamais"); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(entry.DownloadCount.ToString()); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"UID : {entry.AllowedIndividuals.Count}, Syncshells : {entry.AllowedSyncshells.Count}"); + + ImGui.TableNextColumn(); + using (ImRaii.PushId("ownShare" + entry.Id)) + { + if (ImGui.SmallButton("Appliquer en GPose")) + { + _ = _mcdfShareManager.ApplyShareAsync(entry.Id, CancellationToken.None); + } + ImGui.SameLine(); + if (ImGui.SmallButton("Enregistrer")) + { + var baseName = SanitizeFileName(entry.Description, entry.Id.ToString()); + var defaultName = baseName + ".mcdf"; + _fileDialogManager.SaveFileDialog("Enregistrer le partage MCDF", ".mcdf", defaultName, ".mcdf", async (success, path) => + { + if (!success || string.IsNullOrEmpty(path)) return; + await _mcdfShareManager.ExportShareAsync(entry.Id, path, CancellationToken.None).ConfigureAwait(false); + }); + } + ImGui.SameLine(); + if (ImGui.SmallButton("Supprimer")) + { + _ = _mcdfShareManager.DeleteShareAsync(entry.Id); + } + } + } + + ImGui.EndTable(); + } + + ImGui.Separator(); + _uiSharedService.BigText("Partagés avec moi : "); + + if (_mcdfShareManager.SharedShares.Count == 0) + { + ImGui.TextDisabled("Aucun partage MCDF reçu."); + } + else if (ImGui.BeginTable("mcdf-shared-shares", 5, ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersOuter)) + { + ImGui.TableSetupColumn("Description"); + ImGui.TableSetupColumn("Propriétaire"); + ImGui.TableSetupColumn("Expire"); + ImGui.TableSetupColumn("Téléchargements"); + ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.WidthFixed, 180f); + ImGui.TableHeadersRow(); + + foreach (var entry in _mcdfShareManager.SharedShares) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(string.IsNullOrEmpty(entry.Description) ? entry.Id.ToString() : entry.Description); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(string.IsNullOrEmpty(entry.OwnerAlias) ? entry.OwnerUid : entry.OwnerAlias); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(entry.ExpiresAtUtc.HasValue ? entry.ExpiresAtUtc.Value.ToLocalTime().ToString("g") : "Jamais"); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(entry.DownloadCount.ToString()); + + ImGui.TableNextColumn(); + using (ImRaii.PushId("sharedShare" + entry.Id)) + { + if (ImGui.SmallButton("Appliquer")) + { + _ = _mcdfShareManager.ApplyShareAsync(entry.Id, CancellationToken.None); + } + ImGui.SameLine(); + if (ImGui.SmallButton("Enregistrer")) + { + var baseName = SanitizeFileName(entry.Description, entry.Id.ToString()); + var defaultName = baseName + ".mcdf"; + _fileDialogManager.SaveFileDialog("Enregistrer le partage MCDF", ".mcdf", defaultName, ".mcdf", async (success, path) => + { + if (!success || string.IsNullOrEmpty(path)) return; + await _mcdfShareManager.ExportShareAsync(entry.Id, path, CancellationToken.None).ConfigureAwait(false); + }); + } + } + } + + ImGui.EndTable(); + } + } + + private void DrawMcdfShareIndividualDropdown() + { + ImGui.SetNextItemWidth(220f); + var previewSource = string.IsNullOrEmpty(_mcdfShareIndividualDropdownSelection) + ? _mcdfShareIndividualInput + : _mcdfShareIndividualDropdownSelection; + var previewLabel = string.IsNullOrEmpty(previewSource) + ? "Sélectionner un pair synchronisé..." + : FormatPairLabel(previewSource); + + using var combo = ImRaii.Combo("##mcdfShareUidDropdown", previewLabel, ImGuiComboFlags.None); + if (!combo) + { + return; + } + + foreach (var pair in _pairManager.DirectPairs + .OrderBy(p => p.GetNoteOrName() ?? p.UserData.AliasOrUID, StringComparer.OrdinalIgnoreCase)) + { + var normalized = pair.UserData.UID; + var display = FormatPairLabel(normalized); + bool selected = string.Equals(normalized, _mcdfShareIndividualDropdownSelection, StringComparison.OrdinalIgnoreCase); + if (ImGui.Selectable(display, selected)) + { + _mcdfShareIndividualDropdownSelection = normalized; + _mcdfShareIndividualInput = normalized; + } + } + } + + private void DrawMcdfShareSyncshellDropdown() + { + ImGui.SetNextItemWidth(220f); + var previewSource = string.IsNullOrEmpty(_mcdfShareSyncshellDropdownSelection) + ? _mcdfShareSyncshellInput + : _mcdfShareSyncshellDropdownSelection; + var previewLabel = string.IsNullOrEmpty(previewSource) + ? "Sélectionner une syncshell..." + : FormatSyncshellLabel(previewSource); + + using var combo = ImRaii.Combo("##mcdfShareSyncshellDropdown", previewLabel, ImGuiComboFlags.None); + if (!combo) + { + return; + } + + foreach (var group in _pairManager.Groups.Values + .OrderBy(g => _serverConfigurationManager.GetNoteForGid(g.GID) ?? g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase)) + { + var gid = group.GID; + var display = FormatSyncshellLabel(gid); + bool selected = string.Equals(gid, _mcdfShareSyncshellDropdownSelection, StringComparison.OrdinalIgnoreCase); + if (ImGui.Selectable(display, selected)) + { + _mcdfShareSyncshellDropdownSelection = gid; + _mcdfShareSyncshellInput = gid; + } + } + } + + private string NormalizeUidCandidate(string candidate) + { + if (string.IsNullOrWhiteSpace(candidate)) + { + return string.Empty; + } + + var trimmed = candidate.Trim(); + + foreach (var pair in _pairManager.DirectPairs) + { + var alias = pair.UserData.Alias; + var aliasOrUid = pair.UserData.AliasOrUID; + var note = pair.GetNoteOrName(); + + if (string.Equals(pair.UserData.UID, trimmed, StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrEmpty(alias) && string.Equals(alias, trimmed, StringComparison.OrdinalIgnoreCase)) + || string.Equals(aliasOrUid, trimmed, StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrEmpty(note) && string.Equals(note, trimmed, StringComparison.OrdinalIgnoreCase))) + { + return pair.UserData.UID; + } + } + + return trimmed.ToUpperInvariant(); + } + + private string NormalizeSyncshellCandidate(string candidate) + { + if (string.IsNullOrWhiteSpace(candidate)) + { + return string.Empty; + } + + var trimmed = candidate.Trim(); + + foreach (var group in _pairManager.Groups.Values) + { + var alias = group.GroupAlias; + var aliasOrGid = group.GroupAliasOrGID; + var note = _serverConfigurationManager.GetNoteForGid(group.GID); + + if (string.Equals(group.GID, trimmed, StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrEmpty(alias) && string.Equals(alias, trimmed, StringComparison.OrdinalIgnoreCase)) + || string.Equals(aliasOrGid, trimmed, StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrEmpty(note) && string.Equals(note, trimmed, StringComparison.OrdinalIgnoreCase))) + { + return group.GID; + } + } + + return trimmed.ToUpperInvariant(); + } + + private string FormatPairLabel(string candidate) + { + if (string.IsNullOrEmpty(candidate)) + { + return string.Empty; + } + + foreach (var pair in _pairManager.DirectPairs) + { + var alias = pair.UserData.Alias; + var aliasOrUid = pair.UserData.AliasOrUID; + var note = pair.GetNoteOrName(); + + if (string.Equals(pair.UserData.UID, candidate, StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrEmpty(alias) && string.Equals(alias, candidate, StringComparison.OrdinalIgnoreCase)) + || string.Equals(aliasOrUid, candidate, StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrEmpty(note) && string.Equals(note, candidate, StringComparison.OrdinalIgnoreCase))) + { + return string.IsNullOrEmpty(note) ? aliasOrUid : $"{note} ({aliasOrUid})"; + } + } + + return candidate; + } + + private string FormatSyncshellLabel(string candidate) + { + if (string.IsNullOrEmpty(candidate)) + { + return string.Empty; + } + + foreach (var group in _pairManager.Groups.Values) + { + var alias = group.GroupAlias; + var aliasOrGid = group.GroupAliasOrGID; + var note = _serverConfigurationManager.GetNoteForGid(group.GID); + + if (string.Equals(group.GID, candidate, StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrEmpty(alias) && string.Equals(alias, candidate, StringComparison.OrdinalIgnoreCase)) + || string.Equals(aliasOrGid, candidate, StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrEmpty(note) && string.Equals(note, candidate, StringComparison.OrdinalIgnoreCase))) + { + return string.IsNullOrEmpty(note) ? aliasOrGid : $"{note} ({aliasOrGid})"; + } + } + + return candidate; + } + private void DrawMetaInfoData(string selectedGposeActor, bool hasValidGposeTarget, CharaDataMetaInfoExtendedDto data, bool canOpen = false) { ImGuiHelpers.ScaledDummy(5); diff --git a/MareSynchronos/UI/CompactUI.cs b/MareSynchronos/UI/CompactUI.cs index 3ab7c67..a149ef3 100644 --- a/MareSynchronos/UI/CompactUI.cs +++ b/MareSynchronos/UI/CompactUI.cs @@ -14,6 +14,7 @@ using MareSynchronos.Services; using MareSynchronos.Services.Mediator; using MareSynchronos.Services.ServerConfiguration; using MareSynchronos.Services.AutoDetect; +using MareSynchronos.Services.Notifications; using MareSynchronos.UI.Components; using MareSynchronos.UI.Handlers; using MareSynchronos.WebAPI; @@ -28,6 +29,7 @@ using System.Diagnostics; using System.Globalization; using System.Numerics; using System.Reflection; +using System.Threading.Tasks; using System.Linq; namespace MareSynchronos.UI; @@ -57,6 +59,8 @@ public class CompactUi : WindowMediatorSubscriberBase private readonly SettingsUi _settingsUi; private readonly AutoDetectUi _autoDetectUi; private readonly DataAnalysisUi _dataAnalysisUi; + private readonly CharaDataHubUi _charaDataHubUi; + private readonly NotificationTracker _notificationTracker; private bool _buttonState; private string _characterOrCommentFilter = string.Empty; private Pair? _lastAddedUser; @@ -71,6 +75,7 @@ public class CompactUi : WindowMediatorSubscriberBase private bool _visibleOpen = true; private bool _selfAnalysisOpen = false; private List _nearbyEntries = new(); + private int _notificationCount; private const long SelfAnalysisSizeWarningThreshold = 300L * 1024 * 1024; private const long SelfAnalysisTriangleWarningThreshold = 150_000; private CompactUiSection _activeSection = CompactUiSection.VisiblePairs; @@ -84,6 +89,7 @@ public class CompactUi : WindowMediatorSubscriberBase private enum CompactUiSection { VisiblePairs, + Notifications, IndividualPairs, Syncshells, AutoDetect, @@ -108,7 +114,9 @@ public class CompactUi : WindowMediatorSubscriberBase EditProfileUi editProfileUi, SettingsUi settingsUi, AutoDetectUi autoDetectUi, - DataAnalysisUi dataAnalysisUi) + DataAnalysisUi dataAnalysisUi, + CharaDataHubUi charaDataHubUi, + NotificationTracker notificationTracker) : base(logger, mediator, "###UmbraSyncMainUI", performanceCollectorService) { _uiSharedService = uiShared; @@ -126,6 +134,8 @@ public class CompactUi : WindowMediatorSubscriberBase _settingsUi = settingsUi; _autoDetectUi = autoDetectUi; _dataAnalysisUi = dataAnalysisUi; + _charaDataHubUi = charaDataHubUi; + _notificationTracker = notificationTracker; var tagHandler = new TagHandler(_serverManager); _groupPanel = new(this, uiShared, _pairManager, chatService, uidDisplayHandler, _serverManager, _charaDataManager, _autoDetectRequestService); @@ -162,6 +172,8 @@ public class CompactUi : WindowMediatorSubscriberBase } } }); + Mediator.Subscribe(this, msg => _notificationCount = msg.TotalCount); + _notificationCount = _notificationTracker.Count; Flags |= ImGuiWindowFlags.NoDocking; @@ -706,7 +718,7 @@ if (showNearby && pendingInvites > 0) if (!showVisibleCard && !showNearbyCard) { - const string calmMessage = "C'est bien trop calme ici... Il n'y a rien pour le moment."; + const string calmMessage = "C'est bien trop calme ici... Il n'y a personne pour le moment."; using (_uiSharedService.UidFont.Push()) { var regionMin = ImGui.GetWindowContentRegionMin(); @@ -898,6 +910,8 @@ if (showNearby && pendingInvites > 0) ImGuiHelpers.ScaledDummy(6f); DrawConnectionIcon(); ImGuiHelpers.ScaledDummy(12f); + DrawSidebarButton(FontAwesomeIcon.Bell, "Notifications", CompactUiSection.Notifications, true, _notificationCount > 0, _notificationCount, null, ImGuiColors.DalamudOrange); + ImGuiHelpers.ScaledDummy(3f); DrawSidebarButton(FontAwesomeIcon.Eye, "Visible pairs", CompactUiSection.VisiblePairs, isConnected); ImGuiHelpers.ScaledDummy(3f); @@ -912,15 +926,9 @@ if (showNearby && pendingInvites > 0) : "AutoDetect"; DrawSidebarButton(FontAwesomeIcon.BroadcastTower, autoDetectTooltip, CompactUiSection.AutoDetect, isConnected, highlightAutoDetect, pendingInvites); ImGuiHelpers.ScaledDummy(3f); - DrawSidebarButton(FontAwesomeIcon.PersonCircleQuestion, "Character Analysis", CompactUiSection.CharacterAnalysis, isConnected, _dataAnalysisUi.IsOpen, 0, () => - { - Mediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi))); - }); + DrawSidebarButton(FontAwesomeIcon.PersonCircleQuestion, "Character Analysis", CompactUiSection.CharacterAnalysis, isConnected); ImGuiHelpers.ScaledDummy(3f); - DrawSidebarButton(FontAwesomeIcon.Running, "Character Data Hub", CompactUiSection.CharacterDataHub, isConnected, false, 0, () => - { - Mediator.Publish(new UiToggleMessage(typeof(CharaDataHubUi))); - }); + DrawSidebarButton(FontAwesomeIcon.Running, "Character Data Hub", CompactUiSection.CharacterDataHub, isConnected); ImGuiHelpers.ScaledDummy(12f); DrawSidebarButton(FontAwesomeIcon.UserCircle, "Edit Profile", CompactUiSection.EditProfile, isConnected); ImGuiHelpers.ScaledDummy(3f); @@ -930,7 +938,7 @@ if (showNearby && pendingInvites > 0) }); } - private void DrawSidebarButton(FontAwesomeIcon icon, string tooltip, CompactUiSection section, bool enabled = true, bool highlight = false, int badgeCount = 0, Action? onClick = null) + private void DrawSidebarButton(FontAwesomeIcon icon, string tooltip, CompactUiSection section, bool enabled = true, bool highlight = false, int badgeCount = 0, Action? onClick = null, Vector4? highlightColor = null) { using var id = ImRaii.PushId((int)section); float regionWidth = ImGui.GetContentRegionAvail().X; @@ -940,7 +948,7 @@ if (showNearby && pendingInvites > 0) bool isActive = _activeSection == section; - if (DrawSidebarSquareButton(icon, isActive, highlight, enabled, badgeCount)) + if (DrawSidebarSquareButton(icon, isActive, highlight, enabled, badgeCount, highlightColor)) { if (onClick != null) { @@ -970,7 +978,7 @@ if (showNearby && pendingInvites > 0) bool isTogglingDisabled = !hasServer || state is ServerState.Reconnecting or ServerState.Disconnecting; - if (DrawSidebarSquareButton(icon, isLinked, false, !isTogglingDisabled, 0) && !isTogglingDisabled) + if (DrawSidebarSquareButton(icon, isLinked, false, !isTogglingDisabled, 0, null) && !isTogglingDisabled) { ToggleConnection(); } @@ -988,7 +996,7 @@ if (showNearby && pendingInvites > 0) } } - private bool DrawSidebarSquareButton(FontAwesomeIcon icon, bool isActive, bool highlight, bool enabled, int badgeCount) + private bool DrawSidebarSquareButton(FontAwesomeIcon icon, bool isActive, bool highlight, bool enabled, int badgeCount, Vector4? highlightColor) { float size = SidebarIconSize * ImGuiHelpers.GlobalScale; @@ -1021,9 +1029,14 @@ if (showNearby && pendingInvites > 0) start.Y + (size - iconSize.Y) / 2f); uint iconColor = ImGui.ColorConvertFloat4ToU32(new Vector4(0.85f, 0.85f, 0.9f, 1f)); if (highlight) - iconColor = ImGui.ColorConvertFloat4ToU32(new Vector4(0.45f, 0.85f, 0.45f, 1f)); + { + var color = highlightColor ?? new Vector4(0.45f, 0.85f, 0.45f, 1f); + iconColor = ImGui.ColorConvertFloat4ToU32(color); + } else if (isActive) + { iconColor = ImGui.GetColorU32(ImGuiCol.Text); + } ImGui.GetWindowDrawList().AddText(textPos, iconColor, iconText); } @@ -1093,6 +1106,9 @@ if (showNearby && pendingInvites > 0) case CompactUiSection.VisiblePairs: DrawPairSection(PairContentMode.VisibleOnly); break; + case CompactUiSection.Notifications: + DrawNotificationsSection(); + break; case CompactUiSection.IndividualPairs: DrawPairSection(PairContentMode.All); break; @@ -1102,6 +1118,14 @@ if (showNearby && pendingInvites > 0) case CompactUiSection.AutoDetect: DrawAutoDetectSection(); break; + case CompactUiSection.CharacterAnalysis: + if (_dataAnalysisUi.IsOpen) _dataAnalysisUi.IsOpen = false; + _dataAnalysisUi.DrawInline(); + break; + case CompactUiSection.CharacterDataHub: + if (_charaDataHubUi.IsOpen) _charaDataHubUi.IsOpen = false; + _charaDataHubUi.DrawInline(); + break; } DrawNewUserNoteModal(); @@ -1133,6 +1157,98 @@ if (showNearby && pendingInvites > 0) using (ImRaii.PushId("autodetect-inline")) _autoDetectUi.DrawInline(); } + private void DrawNotificationsSection() + { + var notifications = _notificationTracker.GetEntries(); + if (notifications.Count == 0) + { + UiSharedService.ColorTextWrapped("Aucune notification en attente.", ImGuiColors.DalamudGrey3); + return; + } + + foreach (var notification in notifications.OrderByDescending(n => n.CreatedAt)) + { + switch (notification.Category) + { + case NotificationCategory.AutoDetect: + DrawAutoDetectNotification(notification); + break; + default: + UiSharedService.DrawCard($"notification-{notification.Category}-{notification.Id}", () => + { + ImGui.TextUnformatted(notification.Title); + if (!string.IsNullOrEmpty(notification.Description)) + { + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); + ImGui.TextUnformatted(notification.Description); + ImGui.PopStyleColor(); + } + }, stretchWidth: true); + break; + } + + ImGuiHelpers.ScaledDummy(4f); + } + } + + private void DrawAutoDetectNotification(NotificationEntry notification) + { + UiSharedService.DrawCard($"notification-autodetect-{notification.Id}", () => + { + var label = _nearbyPending.Pending.TryGetValue(notification.Id, out var displayName) + ? displayName + : notification.Title; + + ImGui.TextUnformatted(label); + if (!string.IsNullOrEmpty(notification.Description)) + { + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); + ImGui.TextWrapped(notification.Description); + ImGui.PopStyleColor(); + } + + ImGuiHelpers.ScaledDummy(3f); + + bool hasPending = _nearbyPending.Pending.ContainsKey(notification.Id); + using (ImRaii.PushId(notification.Id)) + { + using (ImRaii.Disabled(!hasPending)) + { + if (ImGui.Button("Accepter")) + { + TriggerAcceptAutoDetectNotification(notification.Id); + } + ImGui.SameLine(); + if (ImGui.Button("Refuser")) + { + _nearbyPending.Remove(notification.Id); + } + } + + if (!hasPending) + { + ImGui.SameLine(); + if (ImGui.Button("Effacer")) + { + _notificationTracker.Remove(NotificationCategory.AutoDetect, notification.Id); + } + } + } + }, stretchWidth: true); + } + + private void TriggerAcceptAutoDetectNotification(string uid) + { + _ = Task.Run(async () => + { + bool accepted = await _nearbyPending.AcceptAsync(uid).ConfigureAwait(false); + if (!accepted) + { + Mediator.Publish(new NotificationMessage("AutoDetect", $"Impossible d'accepter l'invitation {uid}.", NotificationType.Warning, TimeSpan.FromSeconds(5))); + } + }); + } + private void DrawNewUserNoteModal() { if (_configService.Current.OpenPopupOnAdd && _pairManager.LastAddedUser != null) @@ -1171,9 +1287,12 @@ if (showNearby && pendingInvites > 0) private static bool RequiresServerConnection(CompactUiSection section) { return section is CompactUiSection.VisiblePairs + or CompactUiSection.Notifications or CompactUiSection.IndividualPairs or CompactUiSection.Syncshells - or CompactUiSection.AutoDetect; + or CompactUiSection.AutoDetect + or CompactUiSection.CharacterAnalysis + or CompactUiSection.CharacterDataHub; } private bool IsAlreadyPairedQuickMenu(Services.Mediator.NearbyEntry entry) @@ -1288,37 +1407,61 @@ if (showNearby && pendingInvites > 0) var originalPos = ImGui.GetCursorPos(); UiSharedService.SetFontScale(1.5f); - Vector2 buttonSize = Vector2.Zero; float spacingX = ImGui.GetStyle().ItemSpacing.X; - - if (_apiController.ServerState is ServerState.Connected) - { - buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Copy); - ImGui.SameLine(ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth() - buttonSize.X); - ImGui.SetCursorPosY(originalPos.Y + uidTextSize.Y / 2f - buttonSize.Y / 2f); - if (_uiSharedService.IconButton(FontAwesomeIcon.Copy)) - { - ImGui.SetClipboardText(_apiController.DisplayName); - } - UiSharedService.AttachToolTip("Copy your UID to clipboard"); - ImGui.SameLine(); - } - - ImGui.SetCursorPos(originalPos); - UiSharedService.SetFontScale(1f); - - float referenceHeight = buttonSize.Y > 0f ? buttonSize.Y : ImGui.GetFrameHeight(); - ImGui.SetCursorPosY(originalPos.Y + referenceHeight / 2f - uidTextSize.Y / 2f - spacingX / 2f); float contentMin = ImGui.GetWindowContentRegionMin().X; float contentMax = ImGui.GetWindowContentRegionMax().X; float availableWidth = contentMax - contentMin; float center = contentMin + availableWidth / 2f; - ImGui.SetCursorPosX(center - uidTextSize.X / 2f); + + bool isConnected = _apiController.ServerState is ServerState.Connected; + float buttonSize = 18f * ImGuiHelpers.GlobalScale; + float textPosY = originalPos.Y + MathF.Max(buttonSize, uidTextSize.Y) / 2f - uidTextSize.Y / 2f; + float textPosX = center - uidTextSize.X / 2f; + + if (isConnected) + { + float buttonX = textPosX - spacingX - buttonSize; + float buttonVerticalOffset = 7f * ImGuiHelpers.GlobalScale; + float buttonY = textPosY + uidTextSize.Y - buttonSize + buttonVerticalOffset; + ImGui.SetCursorPos(new Vector2(buttonX, buttonY)); + if (ImGui.Button("##copy", new Vector2(buttonSize, buttonSize))) + { + ImGui.SetClipboardText(_apiController.DisplayName); + } + var buttonMin = ImGui.GetItemRectMin(); + var drawList = ImGui.GetWindowDrawList(); + using (_uiSharedService.IconFont.Push()) + { + string iconText = FontAwesomeIcon.Copy.ToIconString(); + var baseSize = ImGui.CalcTextSize(iconText); + float maxDimension = MathF.Max(MathF.Max(baseSize.X, baseSize.Y), 1f); + float available = buttonSize - 4f; + float scale = MathF.Min(1f, available / maxDimension); + float iconWidth = baseSize.X * scale; + float iconHeight = baseSize.Y * scale; + var iconPos = new Vector2( + buttonMin.X + (buttonSize - iconWidth) / 2f, + buttonMin.Y + (buttonSize - iconHeight) / 2f); + var font = ImGui.GetFont(); + float fontSize = ImGui.GetFontSize() * scale; + drawList.AddText(font, fontSize, iconPos, ImGui.GetColorU32(ImGuiCol.Text), iconText); + } + UiSharedService.AttachToolTip("Copy your UID to clipboard"); + ImGui.SameLine(0f, spacingX); + } + else + { + ImGui.SetCursorPos(originalPos); + } + + ImGui.SetCursorPos(new Vector2(textPosX, textPosY)); using (_uiSharedService.UidFont.Push()) ImGui.TextColored(GetUidColor(), uidText); - if (_apiController.ServerState is not ServerState.Connected) + UiSharedService.SetFontScale(1f); + + if (!isConnected) UiSharedService.ColorTextWrapped(GetServerError(), GetUidColor()); { if (_apiController.ServerState is ServerState.NoSecretKey) diff --git a/MareSynchronos/UI/DataAnalysisUi.cs b/MareSynchronos/UI/DataAnalysisUi.cs index 36868ae..01b9ac2 100644 --- a/MareSynchronos/UI/DataAnalysisUi.cs +++ b/MareSynchronos/UI/DataAnalysisUi.cs @@ -65,6 +65,19 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase } protected override void DrawInternal() + { + DrawAnalysisContent(); + } + + public void DrawInline() + { + using (ImRaii.PushId("CharacterAnalysisInline")) + { + DrawAnalysisContent(); + } + } + + private void DrawAnalysisContent() { if (_conversionTask != null && !_conversionTask.IsCompleted) { @@ -116,7 +129,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase if (isAnalyzing) { UiSharedService.ColorTextWrapped($"Analyzing {_characterAnalyzer.CurrentFile}/{_characterAnalyzer.TotalFiles}", - ImGuiColors.DalamudYellow); + UiSharedService.AccentColor); if (_uiSharedService.IconTextButton(FontAwesomeIcon.StopCircle, "Cancel analysis")) { _characterAnalyzer.CancelAnalyze(); @@ -127,7 +140,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase if (needAnalysis) { UiSharedService.ColorTextWrapped("Some entries in the analysis have file size not determined yet, press the button below to analyze your current data", - ImGuiColors.DalamudYellow); + UiSharedService.AccentColor); if (_uiSharedService.IconTextButton(FontAwesomeIcon.PlayCircle, "Start analysis (missing entries)")) { _ = _characterAnalyzer.ComputeAnalysis(print: false); @@ -166,7 +179,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase ImGui.TextUnformatted(UiSharedService.ByteToString(_cachedAnalysis!.Sum(c => c.Value.Sum(c => c.Value.OriginalSize)))); ImGui.TextUnformatted("Total size (download size):"); ImGui.SameLine(); - using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudYellow, needAnalysis)) + using (ImRaii.PushColor(ImGuiCol.Text, UiSharedService.AccentColor, needAnalysis)) { ImGui.TextUnformatted(UiSharedService.ByteToString(_cachedAnalysis!.Sum(c => c.Value.Sum(c => c.Value.CompressedSize)))); if (needAnalysis && !isAnalyzing) @@ -180,140 +193,149 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase ImGui.TextUnformatted($"Total modded model triangles: {UiSharedService.TrisToString(_cachedAnalysis.Sum(c => c.Value.Sum(f => f.Value.Triangles)))}"); ImGui.Separator(); - using var tabbar = ImRaii.TabBar("objectSelection"); - foreach (var kvp in _cachedAnalysis) { - using var id = ImRaii.PushId(kvp.Key.ToString()); - string tabText = kvp.Key.ToString(); - using var tab = ImRaii.TabItem(tabText + "###" + kvp.Key.ToString()); - if (tab.Success) + using var objectTabColor = ImRaii.PushColor(ImGuiCol.Tab, UiSharedService.AccentColor); + using var objectTabHoverColor = ImRaii.PushColor(ImGuiCol.TabHovered, UiSharedService.AccentHoverColor); + using var objectTabActiveColor = ImRaii.PushColor(ImGuiCol.TabActive, UiSharedService.AccentActiveColor); + using var tabbar = ImRaii.TabBar("objectSelection"); + foreach (var kvp in _cachedAnalysis) { - var groupedfiles = kvp.Value.Select(v => v.Value).GroupBy(f => f.FileType, StringComparer.Ordinal) - .OrderBy(k => k.Key, StringComparer.Ordinal).ToList(); + using var id = ImRaii.PushId(kvp.Key.ToString()); + string tabText = kvp.Key.ToString(); + using var tab = ImRaii.TabItem(tabText + "###" + kvp.Key.ToString()); + if (tab.Success) + { + var groupedfiles = kvp.Value.Select(v => v.Value).GroupBy(f => f.FileType, StringComparer.Ordinal) + .OrderBy(k => k.Key, StringComparer.Ordinal).ToList(); - ImGui.TextUnformatted("Files for " + kvp.Key); - ImGui.SameLine(); - ImGui.TextUnformatted(kvp.Value.Count.ToString()); - ImGui.SameLine(); + ImGui.TextUnformatted("Files for " + kvp.Key); + ImGui.SameLine(); + ImGui.TextUnformatted(kvp.Value.Count.ToString()); + ImGui.SameLine(); - using (var font = ImRaii.PushFont(UiBuilder.IconFont)) - { - ImGui.TextUnformatted(FontAwesomeIcon.InfoCircle.ToIconString()); - } - if (ImGui.IsItemHovered()) - { - string text = ""; - text = string.Join(Environment.NewLine, groupedfiles - .Select(f => f.Key + ": " + f.Count() + " files, size: " + UiSharedService.ByteToString(f.Sum(v => v.OriginalSize)) - + ", compressed: " + UiSharedService.ByteToString(f.Sum(v => v.CompressedSize)))); - ImGui.SetTooltip(text); - } - ImGui.TextUnformatted($"{kvp.Key} size (actual):"); - ImGui.SameLine(); - ImGui.TextUnformatted(UiSharedService.ByteToString(kvp.Value.Sum(c => c.Value.OriginalSize))); - ImGui.TextUnformatted($"{kvp.Key} size (download size):"); - ImGui.SameLine(); - using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudYellow, needAnalysis)) - { - ImGui.TextUnformatted(UiSharedService.ByteToString(kvp.Value.Sum(c => c.Value.CompressedSize))); - if (needAnalysis && !isAnalyzing) + using (var font = ImRaii.PushFont(UiBuilder.IconFont)) { - ImGui.SameLine(); - using (ImRaii.PushFont(UiBuilder.IconFont)) - ImGui.TextUnformatted(FontAwesomeIcon.ExclamationCircle.ToIconString()); - UiSharedService.AttachToolTip("Click \"Start analysis\" to calculate download size"); + ImGui.TextUnformatted(FontAwesomeIcon.InfoCircle.ToIconString()); } - } - ImGui.TextUnformatted($"{kvp.Key} VRAM usage:"); - ImGui.SameLine(); - var vramUsage = groupedfiles.SingleOrDefault(v => string.Equals(v.Key, "tex", StringComparison.Ordinal)); - if (vramUsage != null) - { - ImGui.TextUnformatted(UiSharedService.ByteToString(vramUsage.Sum(f => f.OriginalSize))); - } - ImGui.TextUnformatted($"{kvp.Key} modded model triangles: {UiSharedService.TrisToString(kvp.Value.Sum(f => f.Value.Triangles))}"); - - ImGui.Separator(); - if (_selectedObjectTab != kvp.Key) - { - _selectedHash = string.Empty; - _selectedObjectTab = kvp.Key; - _selectedFileTypeTab = string.Empty; - _enableBc7ConversionMode = false; - _texturesToConvert.Clear(); - } - - using var fileTabBar = ImRaii.TabBar("fileTabs"); - - foreach (IGrouping? fileGroup in groupedfiles) - { - string fileGroupText = fileGroup.Key + " [" + fileGroup.Count() + "]"; - var requiresCompute = fileGroup.Any(k => !k.IsComputed); - using var tabcol = ImRaii.PushColor(ImGuiCol.Tab, UiSharedService.Color(ImGuiColors.DalamudYellow), requiresCompute); - ImRaii.IEndObject fileTab; - using (var textcol = ImRaii.PushColor(ImGuiCol.Text, UiSharedService.Color(new(0, 0, 0, 1)), - requiresCompute && !string.Equals(_selectedFileTypeTab, fileGroup.Key, StringComparison.Ordinal))) + if (ImGui.IsItemHovered()) { - fileTab = ImRaii.TabItem(fileGroupText + "###" + fileGroup.Key); + string text = ""; + text = string.Join(Environment.NewLine, groupedfiles + .Select(f => f.Key + ": " + f.Count() + " files, size: " + UiSharedService.ByteToString(f.Sum(v => v.OriginalSize)) + + ", compressed: " + UiSharedService.ByteToString(f.Sum(v => v.CompressedSize)))); + ImGui.SetTooltip(text); } - - if (!fileTab) { fileTab.Dispose(); continue; } - - if (!string.Equals(fileGroup.Key, _selectedFileTypeTab, StringComparison.Ordinal)) + ImGui.TextUnformatted($"{kvp.Key} size (actual):"); + ImGui.SameLine(); + ImGui.TextUnformatted(UiSharedService.ByteToString(kvp.Value.Sum(c => c.Value.OriginalSize))); + ImGui.TextUnformatted($"{kvp.Key} size (download size):"); + ImGui.SameLine(); + using (ImRaii.PushColor(ImGuiCol.Text, UiSharedService.AccentColor, needAnalysis)) + { + ImGui.TextUnformatted(UiSharedService.ByteToString(kvp.Value.Sum(c => c.Value.CompressedSize))); + if (needAnalysis && !isAnalyzing) + { + ImGui.SameLine(); + using (ImRaii.PushFont(UiBuilder.IconFont)) + ImGui.TextUnformatted(FontAwesomeIcon.ExclamationCircle.ToIconString()); + UiSharedService.AttachToolTip("Click \"Start analysis\" to calculate download size"); + } + } + ImGui.TextUnformatted($"{kvp.Key} VRAM usage:"); + ImGui.SameLine(); + var vramUsage = groupedfiles.SingleOrDefault(v => string.Equals(v.Key, "tex", StringComparison.Ordinal)); + if (vramUsage != null) + { + ImGui.TextUnformatted(UiSharedService.ByteToString(vramUsage.Sum(f => f.OriginalSize))); + } + ImGui.TextUnformatted($"{kvp.Key} modded model triangles: {UiSharedService.TrisToString(kvp.Value.Sum(f => f.Value.Triangles))}"); + + ImGui.Separator(); + if (_selectedObjectTab != kvp.Key) { - _selectedFileTypeTab = fileGroup.Key; _selectedHash = string.Empty; + _selectedObjectTab = kvp.Key; + _selectedFileTypeTab = string.Empty; _enableBc7ConversionMode = false; _texturesToConvert.Clear(); } - ImGui.TextUnformatted($"{fileGroup.Key} files"); - ImGui.SameLine(); - ImGui.TextUnformatted(fileGroup.Count().ToString()); + using var fileTabColor = ImRaii.PushColor(ImGuiCol.Tab, UiSharedService.AccentColor); + using var fileTabHoverColor = ImRaii.PushColor(ImGuiCol.TabHovered, UiSharedService.AccentHoverColor); + using var fileTabActiveColor = ImRaii.PushColor(ImGuiCol.TabActive, UiSharedService.AccentActiveColor); + using var fileTabBar = ImRaii.TabBar("fileTabs"); - ImGui.TextUnformatted($"{fileGroup.Key} files size (actual):"); - ImGui.SameLine(); - ImGui.TextUnformatted(UiSharedService.ByteToString(fileGroup.Sum(c => c.OriginalSize))); - - ImGui.TextUnformatted($"{fileGroup.Key} files size (download size):"); - ImGui.SameLine(); - ImGui.TextUnformatted(UiSharedService.ByteToString(fileGroup.Sum(c => c.CompressedSize))); - - if (string.Equals(_selectedFileTypeTab, "tex", StringComparison.Ordinal)) + foreach (IGrouping? fileGroup in groupedfiles) { - ImGui.Checkbox("Enable BC7 Conversion Mode", ref _enableBc7ConversionMode); - if (_enableBc7ConversionMode) + string fileGroupText = fileGroup.Key + " [" + fileGroup.Count() + "]"; + var requiresCompute = fileGroup.Any(k => !k.IsComputed); + ImRaii.IEndObject fileTab; + using (var textcol = ImRaii.PushColor(ImGuiCol.Text, UiSharedService.Color(Vector4.One), + requiresCompute && !string.Equals(_selectedFileTypeTab, fileGroup.Key, StringComparison.Ordinal))) { - UiSharedService.ColorText("WARNING BC7 CONVERSION:", ImGuiColors.DalamudYellow); - ImGui.SameLine(); - UiSharedService.ColorText("Converting textures to BC7 is irreversible!", UiSharedService.AccentColor); - UiSharedService.ColorTextWrapped("- Converting textures to BC7 will reduce their size (compressed and uncompressed) drastically. It is recommended to be used for large (4k+) textures." + - Environment.NewLine + "- Some textures, especially ones utilizing colorsets, might not be suited for BC7 conversion and might produce visual artifacts." + - Environment.NewLine + "- Before converting textures, make sure to have the original files of the mod you are converting so you can reimport it in case of issues." + - Environment.NewLine + "- Conversion will convert all found texture duplicates (entries with more than 1 file path) automatically." + - Environment.NewLine + "- Converting textures to BC7 is a very expensive operation and, depending on the amount of textures to convert, will take a while to complete." - , ImGuiColors.DalamudYellow); - if (_texturesToConvert.Count > 0 && _uiSharedService.IconTextButton(FontAwesomeIcon.PlayCircle, "Start conversion of " + _texturesToConvert.Count + " texture(s)")) + fileTab = ImRaii.TabItem(fileGroupText + "###" + fileGroup.Key); + } + + if (!fileTab) { fileTab.Dispose(); continue; } + + if (!string.Equals(fileGroup.Key, _selectedFileTypeTab, StringComparison.Ordinal)) + { + _selectedFileTypeTab = fileGroup.Key; + _selectedHash = string.Empty; + _enableBc7ConversionMode = false; + _texturesToConvert.Clear(); + } + + ImGui.TextUnformatted($"{fileGroup.Key} files"); + ImGui.SameLine(); + ImGui.TextUnformatted(fileGroup.Count().ToString()); + + ImGui.TextUnformatted($"{fileGroup.Key} files size (actual):"); + ImGui.SameLine(); + ImGui.TextUnformatted(UiSharedService.ByteToString(fileGroup.Sum(c => c.OriginalSize))); + + ImGui.TextUnformatted($"{fileGroup.Key} files size (download size):"); + ImGui.SameLine(); + ImGui.TextUnformatted(UiSharedService.ByteToString(fileGroup.Sum(c => c.CompressedSize))); + + if (string.Equals(_selectedFileTypeTab, "tex", StringComparison.Ordinal)) + { + ImGui.Checkbox("Enable BC7 Conversion Mode", ref _enableBc7ConversionMode); + if (_enableBc7ConversionMode) { - var conversionCts = EnsureFreshCts(ref _conversionCancellationTokenSource); - _conversionTask = _ipcManager.Penumbra.ConvertTextureFiles(_logger, _texturesToConvert, _conversionProgress, conversionCts.Token); + UiSharedService.ColorText("WARNING BC7 CONVERSION:", UiSharedService.AccentColor); + ImGui.SameLine(); + UiSharedService.ColorText("Converting textures to BC7 is irreversible!", UiSharedService.AccentColor); + UiSharedService.ColorTextWrapped("- Converting textures to BC7 will reduce their size (compressed and uncompressed) drastically. It is recommended to be used for large (4k+) textures." + + Environment.NewLine + "- Some textures, especially ones utilizing colorsets, might not be suited for BC7 conversion and might produce visual artifacts." + + Environment.NewLine + "- Before converting textures, make sure to have the original files of the mod you are converting so you can reimport it in case of issues." + + Environment.NewLine + "- Conversion will convert all found texture duplicates (entries with more than 1 file path) automatically." + + Environment.NewLine + "- Converting textures to BC7 is a very expensive operation and, depending on the amount of textures to convert, will take a while to complete." + , UiSharedService.AccentColor); + if (_texturesToConvert.Count > 0 && _uiSharedService.IconTextButton(FontAwesomeIcon.PlayCircle, "Start conversion of " + _texturesToConvert.Count + " texture(s)")) + { + var conversionCts = EnsureFreshCts(ref _conversionCancellationTokenSource); + _conversionTask = _ipcManager.Penumbra.ConvertTextureFiles(_logger, _texturesToConvert, _conversionProgress, conversionCts.Token); + } } } + + ImGui.Separator(); + DrawTable(fileGroup); + + fileTab.Dispose(); } - ImGui.Separator(); - DrawTable(fileGroup); - - fileTab.Dispose(); } } + } ImGui.Separator(); ImGui.TextUnformatted("Selected file:"); ImGui.SameLine(); - UiSharedService.ColorText(_selectedHash, ImGuiColors.DalamudYellow); + UiSharedService.ColorText(_selectedHash, UiSharedService.AccentColor); if (_cachedAnalysis[_selectedObjectTab].TryGetValue(_selectedHash, out CharacterAnalyzer.FileDataEntry? item)) { @@ -440,8 +462,8 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase ImGui.TableNextColumn(); if (string.Equals(_selectedHash, item.Hash, StringComparison.Ordinal)) { - ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg1, UiSharedService.Color(ImGuiColors.DalamudYellow)); - ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg0, UiSharedService.Color(ImGuiColors.DalamudYellow)); + ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg1, UiSharedService.Color(UiSharedService.AccentColor)); + ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg0, UiSharedService.Color(UiSharedService.AccentColor)); } ImGui.TextUnformatted(item.Hash); if (ImGui.IsItemClicked()) _selectedHash = item.Hash; @@ -455,7 +477,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase ImGui.TextUnformatted(UiSharedService.ByteToString(item.OriginalSize)); if (ImGui.IsItemClicked()) _selectedHash = item.Hash; ImGui.TableNextColumn(); - using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudYellow, !item.IsComputed)) + using (ImRaii.PushColor(ImGuiCol.Text, UiSharedService.AccentColor, !item.IsComputed)) ImGui.TextUnformatted(UiSharedService.ByteToString(item.CompressedSize)); if (ImGui.IsItemClicked()) _selectedHash = item.Hash; if (string.Equals(fileGroup.Key, "tex", StringComparison.Ordinal)) diff --git a/MareSynchronos/UI/SyncshellAdminUI.cs b/MareSynchronos/UI/SyncshellAdminUI.cs index b72fe79..58c0559 100644 --- a/MareSynchronos/UI/SyncshellAdminUI.cs +++ b/MareSynchronos/UI/SyncshellAdminUI.cs @@ -3,15 +3,19 @@ using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; +using System; using MareSynchronos.API.Data.Enum; using MareSynchronos.API.Data.Extensions; using MareSynchronos.API.Dto.Group; using MareSynchronos.PlayerData.Pairs; using MareSynchronos.Services; +using MareSynchronos.Services.AutoDetect; using MareSynchronos.Services.Mediator; using MareSynchronos.WebAPI; using Microsoft.Extensions.Logging; using System.Globalization; +using System.Threading; +using System.Threading.Tasks; namespace MareSynchronos.UI.Components.Popup; @@ -23,6 +27,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase private readonly List _oneTimeInvites = []; private readonly PairManager _pairManager; private readonly UiSharedService _uiSharedService; + private readonly SyncshellDiscoveryService _syncshellDiscoveryService; private List _bannedUsers = []; private int _multiInvites; private string _newPassword; @@ -30,20 +35,31 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase private Task? _pruneTestTask; private Task? _pruneTask; private int _pruneDays = 14; + private bool _autoDetectStateInitialized; + private bool _autoDetectStateLoading; + private bool _autoDetectToggleInFlight; + private bool _autoDetectVisible; + private bool _autoDetectPasswordDisabled; + private string? _autoDetectMessage; public SyncshellAdminUI(ILogger logger, MareMediator mediator, ApiController apiController, - UiSharedService uiSharedService, PairManager pairManager, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService) + UiSharedService uiSharedService, PairManager pairManager, SyncshellDiscoveryService syncshellDiscoveryService, + GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService) : base(logger, mediator, "Syncshell Admin Panel (" + groupFullInfo.GroupAliasOrGID + ")", performanceCollectorService) { GroupFullInfo = groupFullInfo; _apiController = apiController; _uiSharedService = uiSharedService; _pairManager = pairManager; + _syncshellDiscoveryService = syncshellDiscoveryService; _isOwner = string.Equals(GroupFullInfo.OwnerUID, _apiController.UID, StringComparison.Ordinal); _isModerator = GroupFullInfo.GroupUserInfo.IsModerator(); _newPassword = string.Empty; _multiInvites = 30; _pwChangeSuccess = true; + _autoDetectVisible = groupFullInfo.AutoDetectVisible; + _autoDetectPasswordDisabled = groupFullInfo.PasswordTemporarilyDisabled; + Mediator.Subscribe(this, OnSyncshellAutoDetectStateChanged); IsOpen = true; SizeConstraints = new WindowSizeConstraints() { @@ -59,6 +75,11 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase if (!_isModerator && !_isOwner) return; GroupFullInfo = _pairManager.Groups[GroupFullInfo.Group]; + if (!_autoDetectToggleInFlight && !_autoDetectStateLoading) + { + _autoDetectVisible = GroupFullInfo.AutoDetectVisible; + _autoDetectPasswordDisabled = GroupFullInfo.PasswordTemporarilyDisabled; + } using var id = ImRaii.PushId("syncshell_admin_" + GroupFullInfo.GID); @@ -363,6 +384,13 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase } mgmtTab.Dispose(); + var discoveryTab = ImRaii.TabItem("AutoDetect"); + if (discoveryTab) + { + DrawAutoDetectTab(); + } + discoveryTab.Dispose(); + var permissionTab = ImRaii.TabItem("Permissions"); if (permissionTab) { @@ -448,6 +476,128 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase } } + private void DrawAutoDetectTab() + { + if (!_autoDetectStateInitialized && !_autoDetectStateLoading) + { + _autoDetectStateInitialized = true; + _autoDetectStateLoading = true; + _ = EnsureAutoDetectStateAsync(); + } + + UiSharedService.TextWrapped("Activer l'affichage AutoDetect rend la Syncshell visible dans l'onglet AutoDetect et désactive temporairement le mot de passe."); + ImGuiHelpers.ScaledDummy(4); + + if (_autoDetectStateLoading) + { + ImGui.TextDisabled("Chargement de l'état en cours..."); + } + + if (!string.IsNullOrEmpty(_autoDetectMessage)) + { + UiSharedService.ColorTextWrapped(_autoDetectMessage!, ImGuiColors.DalamudYellow); + } + + bool desiredVisibility = _autoDetectVisible; + using (ImRaii.Disabled(_autoDetectToggleInFlight || _autoDetectStateLoading)) + { + if (ImGui.Checkbox("Afficher cette Syncshell dans l'AutoDetect", ref desiredVisibility)) + { + _ = ToggleAutoDetectAsync(desiredVisibility); + } + } + _uiSharedService.DrawHelpText("Quand cette option est activée, le mot de passe devient inactif tant que la visibilité est maintenue."); + + if (_autoDetectPasswordDisabled && _autoDetectVisible) + { + UiSharedService.ColorTextWrapped("Le mot de passe est actuellement désactivé pendant la visibilité AutoDetect.", ImGuiColors.DalamudYellow); + } + + ImGuiHelpers.ScaledDummy(6); + if (ImGui.Button("Recharger l'état")) + { + _autoDetectStateLoading = true; + _ = EnsureAutoDetectStateAsync(true); + } + } + + private async Task EnsureAutoDetectStateAsync(bool force = false) + { + try + { + var state = await _syncshellDiscoveryService.GetStateAsync(GroupFullInfo.GID, CancellationToken.None).ConfigureAwait(false); + if (state != null) + { + ApplyAutoDetectState(state.AutoDetectVisible, state.PasswordTemporarilyDisabled, true); + _autoDetectMessage = null; + } + else if (force) + { + _autoDetectMessage = "Impossible de récupérer l'état AutoDetect."; + } + } + catch (Exception ex) + { + _autoDetectMessage = force ? $"Erreur lors du rafraîchissement : {ex.Message}" : _autoDetectMessage; + } + finally + { + _autoDetectStateLoading = false; + } + } + + private async Task ToggleAutoDetectAsync(bool desiredVisibility) + { + if (_autoDetectToggleInFlight) + { + return; + } + + _autoDetectToggleInFlight = true; + _autoDetectMessage = null; + + try + { + var success = await _syncshellDiscoveryService.SetVisibilityAsync(GroupFullInfo.GID, desiredVisibility, CancellationToken.None).ConfigureAwait(false); + if (!success) + { + _autoDetectMessage = "Impossible de mettre à jour la visibilité AutoDetect."; + return; + } + + await EnsureAutoDetectStateAsync(true).ConfigureAwait(false); + _autoDetectMessage = desiredVisibility + ? "La Syncshell est désormais visible dans AutoDetect." + : "La Syncshell n'est plus visible dans AutoDetect."; + } + catch (Exception ex) + { + _autoDetectMessage = $"Erreur lors de la mise à jour AutoDetect : {ex.Message}"; + } + finally + { + _autoDetectToggleInFlight = false; + } + } + + private void ApplyAutoDetectState(bool visible, bool passwordDisabled, bool fromServer) + { + _autoDetectVisible = visible; + _autoDetectPasswordDisabled = passwordDisabled; + if (fromServer) + { + GroupFullInfo.AutoDetectVisible = visible; + GroupFullInfo.PasswordTemporarilyDisabled = passwordDisabled; + } + } + + private void OnSyncshellAutoDetectStateChanged(SyncshellAutoDetectStateChanged msg) + { + if (!string.Equals(msg.Gid, GroupFullInfo.GID, StringComparison.OrdinalIgnoreCase)) return; + ApplyAutoDetectState(msg.Visible, msg.PasswordTemporarilyDisabled, true); + _autoDetectMessage = null; + } + public override void OnClose() { Mediator.Publish(new RemoveWindowMessage(this)); diff --git a/MareSynchronos/UI/UISharedService.cs b/MareSynchronos/UI/UISharedService.cs index 9f37058..d2a7456 100644 --- a/MareSynchronos/UI/UISharedService.cs +++ b/MareSynchronos/UI/UISharedService.cs @@ -124,7 +124,11 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase e.OnPreBuild(tk => tk.AddDalamudAssetFont(Dalamud.DalamudAsset.NotoSansJpMedium, new() { SizePx = 27, - GlyphRanges = [0x20, 0x7E, 0] + GlyphRanges = [ + 0x0020, 0x007E, + 0x00A0, 0x017F, + 0 + ] })); }); GameFont = _pluginInterface.UiBuilder.FontAtlas.NewGameFontHandle(new(GameFontFamilyAndSize.Axis12)); diff --git a/MareSynchronos/WebAPI/SignalR/ApiController.Functions.McdfShare.cs b/MareSynchronos/WebAPI/SignalR/ApiController.Functions.McdfShare.cs new file mode 100644 index 0000000..6488848 --- /dev/null +++ b/MareSynchronos/WebAPI/SignalR/ApiController.Functions.McdfShare.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using MareSynchronos.API.Dto.McdfShare; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.Logging; + +namespace MareSynchronos.WebAPI; + +public sealed partial class ApiController +{ + public async Task> McdfShareGetOwn() + { + if (!IsConnected) return []; + try + { + return await _mareHub!.InvokeAsync>(nameof(McdfShareGetOwn)).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Error during {method}", nameof(McdfShareGetOwn)); + return []; + } + } + + public async Task> McdfShareGetShared() + { + if (!IsConnected) return []; + try + { + return await _mareHub!.InvokeAsync>(nameof(McdfShareGetShared)).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Error during {method}", nameof(McdfShareGetShared)); + return []; + } + } + + public async Task McdfShareUpload(McdfShareUploadRequestDto requestDto) + { + if (!IsConnected) return; + try + { + await _mareHub!.InvokeAsync(nameof(McdfShareUpload), requestDto).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Error during {method}", nameof(McdfShareUpload)); + throw; + } + } + + public async Task McdfShareDownload(Guid shareId) + { + if (!IsConnected) return null; + try + { + return await _mareHub!.InvokeAsync(nameof(McdfShareDownload), shareId).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Error during {method}", nameof(McdfShareDownload)); + throw; + } + } + + public async Task McdfShareDelete(Guid shareId) + { + if (!IsConnected) return false; + try + { + return await _mareHub!.InvokeAsync(nameof(McdfShareDelete), shareId).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Error during {method}", nameof(McdfShareDelete)); + throw; + } + } + + public async Task McdfShareUpdate(McdfShareUpdateRequestDto requestDto) + { + if (!IsConnected) return null; + try + { + return await _mareHub!.InvokeAsync(nameof(McdfShareUpdate), requestDto).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Error during {method}", nameof(McdfShareUpdate)); + throw; + } + } +} diff --git a/MareSynchronos/WebAPI/SignalR/ApiController.Functions.SyncshellDiscovery.cs b/MareSynchronos/WebAPI/SignalR/ApiController.Functions.SyncshellDiscovery.cs new file mode 100644 index 0000000..01ba3cc --- /dev/null +++ b/MareSynchronos/WebAPI/SignalR/ApiController.Functions.SyncshellDiscovery.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using MareSynchronos.API.Dto.Group; +using Microsoft.AspNetCore.SignalR.Client; + +namespace MareSynchronos.WebAPI; + +public partial class ApiController +{ + public async Task> SyncshellDiscoveryList() + { + CheckConnection(); + return await _mareHub!.InvokeAsync>(nameof(SyncshellDiscoveryList)).ConfigureAwait(false); + } + + public async Task SyncshellDiscoveryGetState(GroupDto group) + { + CheckConnection(); + return await _mareHub!.InvokeAsync(nameof(SyncshellDiscoveryGetState), group).ConfigureAwait(false); + } + + public async Task SyncshellDiscoverySetVisibility(SyncshellDiscoveryVisibilityRequestDto request) + { + CheckConnection(); + return await _mareHub!.InvokeAsync(nameof(SyncshellDiscoverySetVisibility), request).ConfigureAwait(false); + } + + public async Task SyncshellDiscoveryJoin(GroupDto group) + { + CheckConnection(); + return await _mareHub!.InvokeAsync(nameof(SyncshellDiscoveryJoin), group).ConfigureAwait(false); + } +}