Compare commits

6 Commits

42 changed files with 2755 additions and 427 deletions

3
.gitignore vendored
View File

@@ -3,6 +3,7 @@
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
.idea
qodana.yaml
# User-specific files
*.rsuser
*.suo
@@ -13,6 +14,8 @@
MareSynchronos/.DS_Store
*.zip
UmbraServer_extracted/
NuGet.config
Directory.Build.props
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs

Submodule MareAPI updated: 0abb078c21...d105d20507

View File

@@ -87,6 +87,8 @@ public class MareConfig : IMareConfiguration
public bool ExtraChatTags { get; set; } = false;
public bool TypingIndicatorShowOnNameplates { get; set; } = true;
public bool TypingIndicatorShowOnPartyList { get; set; } = true;
public bool TypingIndicatorEnabled { get; set; } = true;
public bool TypingIndicatorShowSelf { get; set; } = true;
public TypingIndicatorBubbleSize TypingIndicatorBubbleSize { get; set; } = TypingIndicatorBubbleSize.Large;
public bool MareAPI { get; set; } = true;

View File

@@ -0,0 +1,12 @@
using System;
using System.Collections.Generic;
using MareSynchronos.MareConfiguration.Models;
namespace MareSynchronos.MareConfiguration.Configurations;
[Serializable]
public class NotificationsConfig : IMareConfiguration
{
public List<StoredNotification> Notifications { get; set; } = new();
public int Version { get; set; } = 1;
}

View File

@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
namespace MareSynchronos.MareConfiguration.Models;
[Serializable]
public class StoredNotification
{
public string Category { get; set; } = string.Empty; // name of enum NotificationCategory
public string Id { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,14 @@
using MareSynchronos.MareConfiguration.Configurations;
namespace MareSynchronos.MareConfiguration;
public class NotificationsConfigService : ConfigurationServiceBase<NotificationsConfig>
{
public const string ConfigName = "notifications.json";
public NotificationsConfigService(string configDir) : base(configDir)
{
}
public override string ConfigurationName => ConfigName;
}

View File

@@ -50,6 +50,8 @@
<PropertyGroup>
<SourceRevisionId>build$([System.DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ss:fffZ"))</SourceRevisionId>
<ImplicitUsings>enable</ImplicitUsings>
<DisablePackageVulnerabilityAnalysis>true</DisablePackageVulnerabilityAnalysis>
<NoWarn>$(NoWarn);NU1900</NoWarn>
</PropertyGroup>
<ItemGroup>

View File

@@ -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<MareSynchronos.Services.AutoDetect.NearbyDiscoveryService>();
collection.AddSingleton<MareSynchronos.Services.AutoDetect.NearbyPendingService>();
collection.AddSingleton<MareSynchronos.Services.AutoDetect.AutoDetectSuppressionService>();
collection.AddSingleton<MareSynchronos.Services.AutoDetect.SyncshellDiscoveryService>();
collection.AddSingleton<MarePlugin>();
collection.AddSingleton<MareProfileManager>();
collection.AddSingleton<GameObjectHandlerFactory>();
@@ -126,6 +128,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton<CharaDataCharacterHandler>();
collection.AddSingleton<CharaDataNearbyManager>();
collection.AddSingleton<CharaDataGposeTogetherManager>();
collection.AddSingleton<McdfShareManager>();
collection.AddSingleton<VfxSpawnManager>();
collection.AddSingleton<BlockedCharacterHandler>();
@@ -151,6 +154,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton<PartyListTypingService>();
collection.AddSingleton<TypingIndicatorStateService>();
collection.AddSingleton<ChatTwoCompatibilityService>();
collection.AddSingleton<NotificationTracker>();
collection.AddSingleton((s) => new MareConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new ServerConfigService(pluginInterface.ConfigDirectory.FullName));
@@ -163,6 +167,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton((s) => new ServerBlockConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new CharaDataConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new RemoteConfigCacheService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new NotificationsConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<MareConfigService>());
collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<ServerConfigService>());
collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<NotesConfigService>());
@@ -174,6 +179,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<ServerBlockConfigService>());
collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<CharaDataConfigService>());
collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<RemoteConfigCacheService>());
collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<NotificationsConfigService>());
collection.AddSingleton<ConfigurationMigrator>();
collection.AddSingleton<ConfigurationSaveService>();
@@ -186,6 +192,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddScoped<CompactUi>();
collection.AddScoped<EditProfileUi>();
collection.AddScoped<DataAnalysisUi>();
collection.AddScoped<CharaDataHubUi>();
collection.AddScoped<AutoDetectUi>();
collection.AddScoped<WindowMediatorSubscriberBase>(sp => sp.GetRequiredService<SettingsUi>());
collection.AddScoped<WindowMediatorSubscriberBase>(sp => sp.GetRequiredService<CompactUi>());
@@ -227,6 +234,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddHostedService(p => p.GetRequiredService<MarePlugin>());
collection.AddHostedService(p => p.GetRequiredService<IpcProvider>());
collection.AddHostedService(p => p.GetRequiredService<MareSynchronos.Services.AutoDetect.NearbyDiscoveryService>());
collection.AddHostedService(p => p.GetRequiredService<MareSynchronos.Services.AutoDetect.SyncshellDiscoveryService>());
collection.AddHostedService(p => p.GetRequiredService<ChatTwoCompatibilityService>());
collection.AddHostedService(p => p.GetRequiredService<MareSynchronos.Services.AutoDetect.AutoDetectSuppressionService>());
})

View File

@@ -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<string, string> _pending = new(StringComparer.Ordinal);
private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(1);
private static readonly Regex ReqRegex = new(@"^Nearby Request: .+ \[(?<uid>[A-Z0-9]+)\]$", RegexOptions.Compiled | RegexOptions.ExplicitCapture, RegexTimeout);
private static readonly Regex AcceptRegex = new(@"^Nearby Accept: .+ \[(?<uid>[A-Z0-9]+)\]$", RegexOptions.Compiled | RegexOptions.ExplicitCapture, RegexTimeout);
public NearbyPendingService(ILogger<NearbyPendingService> logger, MareMediator mediator, ApiController api, AutoDetectRequestService requestService)
public NearbyPendingService(ILogger<NearbyPendingService> logger, MareMediator mediator, ApiController api, AutoDetectRequestService requestService, NotificationTracker notificationTracker)
{
_logger = logger;
_mediator = mediator;
_api = api;
_requestService = requestService;
_notificationTracker = notificationTracker;
_mediator.Subscribe<NotificationMessage>(this, OnNotification);
_mediator.Subscribe<ManualPairInviteMessage>(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<bool> 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)

View File

@@ -0,0 +1,158 @@
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<SyncshellDiscoveryService> _logger;
private readonly MareMediator _mediator;
private readonly ApiController _apiController;
private readonly SemaphoreSlim _refreshSemaphore = new(1, 1);
private readonly object _entriesLock = new();
private List<SyncshellDiscoveryEntryDto> _entries = [];
private string? _lastError;
private bool _isRefreshing;
public SyncshellDiscoveryService(ILogger<SyncshellDiscoveryService> logger, MareMediator mediator, ApiController apiController)
{
_logger = logger;
_mediator = mediator;
_apiController = apiController;
}
public MareMediator Mediator => _mediator;
public IReadOnlyList<SyncshellDiscoveryEntryDto> Entries
{
get
{
lock (_entriesLock)
{
return _entries.ToList();
}
}
}
public bool IsRefreshing => _isRefreshing;
public string? LastError => _lastError;
public async Task<bool> 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<SyncshellDiscoveryStateDto?> 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<bool> SetVisibilityAsync(string gid, bool visible, CancellationToken ct)
{
return await SetVisibilityAsync(gid, visible, null, null, null, null, null, ct).ConfigureAwait(false);
}
public async Task<bool> SetVisibilityAsync(string gid, bool visible, int? displayDurationHours,
int[]? activeWeekdays, TimeSpan? timeStartLocal, TimeSpan? timeEndLocal, string? timeZone,
CancellationToken ct)
{
try
{
var request = new SyncshellDiscoveryVisibilityRequestDto
{
GID = gid,
AutoDetectVisible = visible,
DisplayDurationHours = displayDurationHours,
ActiveWeekdays = activeWeekdays,
TimeStartLocal = timeStartLocal.HasValue ? new DateTime(timeStartLocal.Value.Ticks).ToString("HH:mm") : null,
TimeEndLocal = timeEndLocal.HasValue ? new DateTime(timeEndLocal.Value.Ticks).ToString("HH:mm") : null,
TimeZone = timeZone,
};
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<ConnectedMessage>(this, msg =>
{
_ = RefreshAsync(CancellationToken.None);
});
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_mediator.UnsubscribeAll(this);
return Task.CompletedTask;
}
}

View File

@@ -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<byte[]?> 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<List<string>> UploadFiles(List<string> fileList, ValueProgress<string> uploadProgress, CancellationToken token)
{
return await _fileUploadManager.UploadFiles(fileList, uploadProgress, token).ConfigureAwait(false);

View File

@@ -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<string> 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;

View File

@@ -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<McdfShareManager> _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<McdfShareEntryDto> _ownShares = new();
private readonly List<McdfShareEntryDto> _sharedWithMe = new();
private Task? _currentTask;
public McdfShareManager(ILogger<McdfShareManager> logger, ApiController apiController,
CharaDataFileHandler fileHandler, CharaDataManager charaDataManager,
ServerConfigurationManager serverConfigurationManager)
{
_logger = logger;
_apiController = apiController;
_fileHandler = fileHandler;
_charaDataManager = charaDataManager;
_serverConfigurationManager = serverConfigurationManager;
}
public IReadOnlyList<McdfShareEntryDto> OwnShares => _ownShares;
public IReadOnlyList<McdfShareEntryDto> 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<string> allowedIndividuals, IReadOnlyList<string> 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<byte[]?> 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<Task> 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);
}
}

View File

@@ -8,6 +8,7 @@ using Dalamud.Plugin.Services;
using MareSynchronos.API.Data;
using MareSynchronos.Interop;
using MareSynchronos.MareConfiguration;
using MareSynchronos.API.Data.Enum;
using MareSynchronos.MareConfiguration.Models;
using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.Services.Mediator;
@@ -38,6 +39,7 @@ public class ChatService : DisposableMediatorSubscriberBase
private CancellationTokenSource? _typingCts;
private bool _isTypingAnnounced;
private DateTime _lastTypingSent = DateTime.MinValue;
private TypingScope _lastScope = TypingScope.Unknown;
private static readonly TimeSpan TypingIdle = TimeSpan.FromSeconds(2);
private static readonly TimeSpan TypingResendInterval = TimeSpan.FromMilliseconds(750);
@@ -79,7 +81,7 @@ public class ChatService : DisposableMediatorSubscriberBase
if (_gameChatHooks.IsValueCreated)
_gameChatHooks.Value!.Dispose();
}
public void NotifyTypingKeystroke()
public void NotifyTypingKeystroke(TypingScope scope)
{
lock (_typingLock)
{
@@ -88,11 +90,12 @@ public class ChatService : DisposableMediatorSubscriberBase
{
_ = Task.Run(async () =>
{
try { await _apiController.UserSetTypingState(true).ConfigureAwait(false); }
try { await _apiController.UserSetTypingState(true, scope).ConfigureAwait(false); }
catch (Exception ex) { _logger.LogDebug(ex, "NotifyTypingKeystroke: failed to send typing=true"); }
});
_isTypingAnnounced = true;
_lastTypingSent = now;
_lastScope = scope;
}
_typingCts?.Cancel();
@@ -105,7 +108,7 @@ public class ChatService : DisposableMediatorSubscriberBase
try
{
await Task.Delay(TypingIdle, token).ConfigureAwait(false);
await _apiController.UserSetTypingState(false).ConfigureAwait(false);
await _apiController.UserSetTypingState(false, _lastScope).ConfigureAwait(false);
}
catch (TaskCanceledException)
{
@@ -140,7 +143,7 @@ public class ChatService : DisposableMediatorSubscriberBase
{
_ = Task.Run(async () =>
{
try { await _apiController.UserSetTypingState(false).ConfigureAwait(false); }
try { await _apiController.UserSetTypingState(false, _lastScope).ConfigureAwait(false); }
catch (Exception ex) { _logger.LogDebug(ex, "ClearTypingState: failed to send typing=false"); }
});
_isTypingAnnounced = false;

View File

@@ -10,6 +10,8 @@ using FFXIVClientStructs.FFXIV.Client.UI.Shell;
using Microsoft.Extensions.Logging;
using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.WebAPI;
using MareSynchronos.MareConfiguration;
using MareSynchronos.API.Data.Enum;
namespace MareSynchronos.Services;
@@ -24,16 +26,18 @@ public sealed class ChatTypingDetectionService : IDisposable
private readonly ApiController _apiController;
private readonly PairManager _pairManager;
private readonly IPartyList _partyList;
private readonly MareConfigService _configService;
private string _lastChatText = string.Empty;
private bool _isTyping;
private bool _notifyingRemote;
private bool _serverSupportWarnLogged;
private bool _remoteNotificationsEnabled;
private bool _subscribed;
public ChatTypingDetectionService(ILogger<ChatTypingDetectionService> logger, IFramework framework,
IClientState clientState, IGameGui gameGui, ChatService chatService, PairManager pairManager, IPartyList partyList,
TypingIndicatorStateService typingStateService, ApiController apiController)
TypingIndicatorStateService typingStateService, ApiController apiController, MareConfigService configService)
{
_logger = logger;
_framework = framework;
@@ -44,17 +48,50 @@ public sealed class ChatTypingDetectionService : IDisposable
_partyList = partyList;
_typingStateService = typingStateService;
_apiController = apiController;
_configService = configService;
_framework.Update += OnFrameworkUpdate;
Subscribe();
_logger.LogInformation("ChatTypingDetectionService initialized");
}
public void Dispose()
{
_framework.Update -= OnFrameworkUpdate;
Unsubscribe();
ResetTypingState();
}
public void SoftRestart()
{
try
{
_logger.LogInformation("TypingDetection: soft restarting");
Unsubscribe();
ResetTypingState();
_chatService.ClearTypingState();
_typingStateService.ClearAll();
Subscribe();
_logger.LogInformation("TypingDetection: soft restart completed");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "TypingDetection: soft restart failed");
}
}
private void Subscribe()
{
if (_subscribed) return;
_framework.Update += OnFrameworkUpdate;
_subscribed = true;
}
private void Unsubscribe()
{
if (!_subscribed) return;
_framework.Update -= OnFrameworkUpdate;
_subscribed = false;
}
private void OnFrameworkUpdate(IFramework framework)
{
try
@@ -65,6 +102,13 @@ public sealed class ChatTypingDetectionService : IDisposable
return;
}
if (!_configService.Current.TypingIndicatorEnabled)
{
ResetTypingState();
_chatService.ClearTypingState();
return;
}
if (!TryGetChatInput(out var chatText) || string.IsNullOrEmpty(chatText))
{
ResetTypingState();
@@ -89,7 +133,8 @@ public sealed class ChatTypingDetectionService : IDisposable
{
if (notifyRemote)
{
_chatService.NotifyTypingKeystroke();
var scope = GetCurrentTypingScope();
_chatService.NotifyTypingKeystroke(scope);
_notifyingRemote = true;
}
@@ -120,6 +165,35 @@ public sealed class ChatTypingDetectionService : IDisposable
_typingStateService.SetSelfTypingLocal(false);
}
private unsafe TypingScope GetCurrentTypingScope()
{
try
{
var shellModule = RaptureShellModule.Instance();
if (shellModule == null)
return TypingScope.Unknown;
var chatType = (XivChatType)shellModule->ChatType;
switch (chatType)
{
case XivChatType.Say:
case XivChatType.Shout:
case XivChatType.Yell:
return TypingScope.Proximity;
case XivChatType.Party:
return TypingScope.Party;
case XivChatType.CrossParty:
return TypingScope.CrossParty;
default:
return TypingScope.Unknown;
}
}
catch
{
return TypingScope.Unknown;
}
}
private static bool IsIgnoredCommand(string chatText)
{
if (string.IsNullOrWhiteSpace(chatText))
@@ -146,6 +220,11 @@ public sealed class ChatTypingDetectionService : IDisposable
{
try
{
if (!_configService.Current.TypingIndicatorEnabled)
{
return false;
}
var supportsTypingState = _apiController.SystemInfoDto.SupportsTypingState;
var connected = _apiController.IsConnected;
if (!connected || !supportsTypingState)

View File

@@ -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();
}

View File

@@ -115,12 +115,15 @@ public record NearbyEntry(string Name, ushort WorldId, float Distance, bool IsMa
public record DiscoveryListUpdated(List<NearbyEntry> Entries) : MessageBase;
public record NearbyDetectionToggled(bool Enabled) : MessageBase;
public record AllowPairRequestsToggled(bool Enabled) : MessageBase;
public record SyncshellDiscoveryUpdated(List<SyncshellDiscoveryEntryDto> 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

View File

@@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MareSynchronos.Services.Mediator;
using MareSynchronos.MareConfiguration;
using MareSynchronos.MareConfiguration.Configurations;
using MareSynchronos.MareConfiguration.Models;
namespace MareSynchronos.Services.Notifications;
public enum NotificationCategory
{
AutoDetect,
Syncshell,
}
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 static NotificationEntry SyncshellPublic(string gid, string aliasOrGid)
=> new(NotificationCategory.Syncshell, gid, $"Syncshell publique: {aliasOrGid}", "La Syncshell est désormais visible via AutoDetect.", DateTime.UtcNow);
public static NotificationEntry SyncshellNotPublic(string gid, string aliasOrGid)
=> new(NotificationCategory.Syncshell, gid, $"Syncshell non publique: {aliasOrGid}", "La Syncshell n'est plus visible via AutoDetect.", DateTime.UtcNow);
}
public sealed class NotificationTracker
{
private const int MaxStored = 100;
private readonly MareMediator _mediator;
private readonly NotificationsConfigService _configService;
private readonly Dictionary<(NotificationCategory Category, string Id), NotificationEntry> _entries = new();
private readonly object _lock = new();
public NotificationTracker(MareMediator mediator, NotificationsConfigService configService)
{
_mediator = mediator;
_configService = configService;
LoadPersisted();
PublishState();
}
public void Upsert(NotificationEntry entry)
{
lock (_lock)
{
_entries[(entry.Category, entry.Id)] = entry;
TrimIfNecessary_NoLock();
Persist_NoLock();
}
PublishState();
}
public void Remove(NotificationCategory category, string id)
{
lock (_lock)
{
_entries.Remove((category, id));
Persist_NoLock();
}
PublishState();
}
public IReadOnlyList<NotificationEntry> 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));
}
private void LoadPersisted()
{
try
{
var list = _configService.Current.Notifications ?? new List<StoredNotification>();
foreach (var s in list)
{
if (!Enum.TryParse<NotificationCategory>(s.Category, out var cat)) continue;
var entry = new NotificationEntry(cat, s.Id, s.Title, s.Description, s.CreatedAtUtc);
_entries[(entry.Category, entry.Id)] = entry;
}
TrimIfNecessary_NoLock();
}
catch
{
// ignore load errors, start empty
}
}
private void Persist_NoLock()
{
try
{
var stored = _entries.Values
.OrderBy(e => e.CreatedAt)
.Select(e => new StoredNotification
{
Category = e.Category.ToString(),
Id = e.Id,
Title = e.Title,
Description = e.Description,
CreatedAtUtc = e.CreatedAt
})
.ToList();
_configService.Current.Notifications = stored;
_configService.Save();
}
catch
{
// ignore persistence errors
}
}
private void TrimIfNecessary_NoLock()
{
if (_entries.Count <= MaxStored) return;
foreach (var kv in _entries.Values.OrderByDescending(v => v.CreatedAt).Skip(MaxStored).ToList())
{
_entries.Remove((kv.Category, kv.Id));
}
}
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Linq;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Plugin.Services;
@@ -17,22 +18,29 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
private readonly INotificationManager _notificationManager;
private readonly IChatGui _chatGui;
private readonly MareConfigService _configurationService;
private readonly Services.Notifications.NotificationTracker _notificationTracker;
private readonly PlayerData.Pairs.PairManager _pairManager;
public NotificationService(ILogger<NotificationService> logger, MareMediator mediator,
DalamudUtilService dalamudUtilService,
INotificationManager notificationManager,
IChatGui chatGui, MareConfigService configurationService) : base(logger, mediator)
IChatGui chatGui, MareConfigService configurationService,
Services.Notifications.NotificationTracker notificationTracker,
PlayerData.Pairs.PairManager pairManager) : base(logger, mediator)
{
_dalamudUtilService = dalamudUtilService;
_notificationManager = notificationManager;
_chatGui = chatGui;
_configurationService = configurationService;
_notificationTracker = notificationTracker;
_pairManager = pairManager;
}
public Task StartAsync(CancellationToken cancellationToken)
{
Mediator.Subscribe<NotificationMessage>(this, ShowNotification);
Mediator.Subscribe<DualNotificationMessage>(this, ShowDualNotification);
Mediator.Subscribe<Services.Mediator.SyncshellAutoDetectStateChanged>(this, OnSyncshellAutoDetectStateChanged);
return Task.CompletedTask;
}
@@ -113,6 +121,31 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
ShowChat(baseMsg);
}
private void OnSyncshellAutoDetectStateChanged(SyncshellAutoDetectStateChanged msg)
{
try
{
if (msg.Visible) return; // only handle transition to not visible
var gid = msg.Gid;
// Try to resolve alias from PairManager snapshot; fallback to gid
var alias = _pairManager.Groups.Values.FirstOrDefault(g => string.Equals(g.GID, gid, StringComparison.OrdinalIgnoreCase))?.GroupAliasOrGID ?? gid;
var title = $"Syncshell non publique: {alias}";
var message = "La Syncshell n'est plus visible via AutoDetect.";
// Show toast + chat
ShowDualNotification(new DualNotificationMessage(title, message, NotificationType.Info, TimeSpan.FromSeconds(4)));
// Persist into notification center
_notificationTracker.Upsert(Services.Notifications.NotificationEntry.SyncshellNotPublic(gid, alias));
}
catch
{
// ignore failures
}
}
private static bool ShouldForceChat(NotificationMessage msg, out bool appendInstruction)
{
appendInstruction = false;

View File

@@ -41,8 +41,8 @@ public class PartyListTypingService : DisposableMediatorSubscriberBase
public void Draw()
{
if (!_configService.Current.TypingIndicatorEnabled) return;
if (!_configService.Current.TypingIndicatorShowOnPartyList) return;
// Build map of visible users by AliasOrUID -> UID (case-insensitive)
var visibleByAlias = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{

View File

@@ -5,24 +5,27 @@ using MareSynchronos.Services.Mediator;
using MareSynchronos.WebAPI;
using Microsoft.Extensions.Logging;
using MareSynchronos.API.Data;
using MareSynchronos.MareConfiguration;
namespace MareSynchronos.Services;
public sealed class TypingIndicatorStateService : IMediatorSubscriber, IDisposable
{
private sealed record TypingEntry(UserData User, DateTime FirstSeen, DateTime LastUpdate);
private sealed record TypingEntry(UserData User, DateTime FirstSeen, DateTime LastUpdate, MareSynchronos.API.Data.Enum.TypingScope Scope);
private readonly ConcurrentDictionary<string, TypingEntry> _typingUsers = new(StringComparer.Ordinal);
private readonly ApiController _apiController;
private readonly ILogger<TypingIndicatorStateService> _logger;
private readonly MareConfigService _configService;
private DateTime _selfTypingLast = DateTime.MinValue;
private DateTime _selfTypingStart = DateTime.MinValue;
private bool _selfTypingActive;
public TypingIndicatorStateService(ILogger<TypingIndicatorStateService> logger, MareMediator mediator, ApiController apiController)
public TypingIndicatorStateService(ILogger<TypingIndicatorStateService> logger, MareMediator mediator, ApiController apiController, MareConfigService configService)
{
_logger = logger;
_apiController = apiController;
_configService = configService;
Mediator = mediator;
mediator.Subscribe<UserTypingStateMessage>(this, OnTypingState);
@@ -51,8 +54,19 @@ public sealed class TypingIndicatorStateService : IMediatorSubscriber, IDisposab
_selfTypingActive = isTyping;
}
public void ClearAll()
{
_typingUsers.Clear();
_selfTypingActive = false;
_selfTypingStart = DateTime.MinValue;
_selfTypingLast = DateTime.MinValue;
_logger.LogDebug("TypingIndicatorStateService: cleared all typing state");
}
private void OnTypingState(UserTypingStateMessage msg)
{
if (!_configService.Current.TypingIndicatorEnabled)
return;
var uid = msg.Typing.User.UID;
var now = DateTime.UtcNow;
@@ -74,8 +88,8 @@ public sealed class TypingIndicatorStateService : IMediatorSubscriber, IDisposab
else if (msg.Typing.IsTyping)
{
_typingUsers.AddOrUpdate(uid,
_ => new TypingEntry(msg.Typing.User, now, now),
(_, existing) => new TypingEntry(msg.Typing.User, existing.FirstSeen, now));
_ => new TypingEntry(msg.Typing.User, now, now, msg.Typing.Scope),
(_, existing) => new TypingEntry(msg.Typing.User, existing.FirstSeen, now, msg.Typing.Scope));
}
else
{
@@ -101,7 +115,7 @@ public sealed class TypingIndicatorStateService : IMediatorSubscriber, IDisposab
return true;
}
public IReadOnlyDictionary<string, (UserData User, DateTime FirstSeen, DateTime LastUpdate)> GetActiveTypers(TimeSpan maxAge)
public IReadOnlyDictionary<string, (UserData User, DateTime FirstSeen, DateTime LastUpdate, MareSynchronos.API.Data.Enum.TypingScope Scope)> GetActiveTypers(TimeSpan maxAge)
{
var now = DateTime.UtcNow;
foreach (var kvp in _typingUsers.ToArray())
@@ -112,6 +126,6 @@ public sealed class TypingIndicatorStateService : IMediatorSubscriber, IDisposab
}
}
return _typingUsers.ToDictionary(k => k.Key, v => (v.Value.User, v.Value.FirstSeen, v.Value.LastUpdate), StringComparer.Ordinal);
return _typingUsers.ToDictionary(k => k.Key, v => (v.Value.User, v.Value.FirstSeen, v.Value.LastUpdate, v.Value.Scope), StringComparer.Ordinal);
}
}

View File

@@ -1,7 +1,9 @@
using MareSynchronos.API.Dto.Group;
using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.Services.AutoDetect;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Services.ServerConfiguration;
using MareSynchronos.Services.Notifications;
using MareSynchronos.UI;
using MareSynchronos.UI.Components.Popup;
using MareSynchronos.WebAPI;
@@ -19,25 +21,29 @@ public class UiFactory
private readonly ServerConfigurationManager _serverConfigManager;
private readonly MareProfileManager _mareProfileManager;
private readonly PerformanceCollectorService _performanceCollectorService;
private readonly SyncshellDiscoveryService _syncshellDiscoveryService;
private readonly NotificationTracker _notificationTracker;
public UiFactory(ILoggerFactory loggerFactory, MareMediator mareMediator, ApiController apiController,
UiSharedService uiSharedService, PairManager pairManager, ServerConfigurationManager serverConfigManager,
MareProfileManager mareProfileManager, PerformanceCollectorService performanceCollectorService)
UiSharedService uiSharedService, PairManager pairManager, SyncshellDiscoveryService syncshellDiscoveryService, ServerConfigurationManager serverConfigManager,
MareProfileManager mareProfileManager, PerformanceCollectorService performanceCollectorService, NotificationTracker notificationTracker)
{
_loggerFactory = loggerFactory;
_mareMediator = mareMediator;
_apiController = apiController;
_uiSharedService = uiSharedService;
_pairManager = pairManager;
_syncshellDiscoveryService = syncshellDiscoveryService;
_serverConfigManager = serverConfigManager;
_mareProfileManager = mareProfileManager;
_performanceCollectorService = performanceCollectorService;
_notificationTracker = notificationTracker;
}
public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto)
{
return new SyncshellAdminUI(_loggerFactory.CreateLogger<SyncshellAdminUI>(), _mareMediator,
_apiController, _uiSharedService, _pairManager, dto, _performanceCollectorService);
_apiController, _uiSharedService, _pairManager, _syncshellDiscoveryService, dto, _performanceCollectorService, _notificationTracker);
}
public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair)

View File

@@ -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<Services.Mediator.NearbyEntry> _entries;
private readonly HashSet<string> _acceptInFlight = new(StringComparer.Ordinal);
private readonly SyncshellDiscoveryService _syncshellDiscoveryService;
private List<SyncshellDiscoveryEntryDto> _syncshellEntries = [];
private bool _syncshellInitialized;
private readonly HashSet<string> _syncshellJoinInFlight = new(StringComparer.OrdinalIgnoreCase);
private string? _syncshellLastError;
public AutoDetectUi(ILogger<AutoDetectUi> 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<Services.Mediator.DiscoveryListUpdated>(this, OnDiscoveryUpdated);
Mediator.Subscribe<SyncshellDiscoveryUpdated>(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

View File

@@ -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
{

View File

@@ -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
{

View File

@@ -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)
{

View File

@@ -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;

View File

@@ -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<string> _mcdfShareAllowedIndividuals = new();
private readonly List<string> _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<CharaDataHubUi> 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<GposeStartMessage>(this, (_) => IsOpen |= _configService.Current.OpenMareHubOnGposeStart);
Mediator.Subscribe<OpenCharaDataHubWithFilterMessage>(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,469 @@ 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");
var style = ImGui.GetStyle();
float BtnWidth(string label) => ImGui.CalcTextSize(label).X + style.FramePadding.X * 2f;
float ownActionsWidth = BtnWidth("Appliquer en GPose") + style.ItemSpacing.X + BtnWidth("Enregistrer") + style.ItemSpacing.X + BtnWidth("Supprimer") + 2f; // small margin
ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.WidthFixed, ownActionsWidth);
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}");
if (ImGui.IsItemHovered())
{
ImGui.BeginTooltip();
if (entry.AllowedIndividuals.Count > 0)
{
ImGui.TextUnformatted("UID autorisés:");
foreach (var uid in entry.AllowedIndividuals)
ImGui.BulletText(FormatUidWithName(uid));
}
else
{
ImGui.TextDisabled("Aucun UID autorisé");
}
ImGui.Separator();
if (entry.AllowedSyncshells.Count > 0)
{
ImGui.TextUnformatted("Syncshells autorisées:");
foreach (var gid in entry.AllowedSyncshells)
ImGui.BulletText(FormatSyncshellLabel(gid));
}
else
{
ImGui.TextDisabled("Aucune syncshell autorisée");
}
ImGui.EndTooltip();
}
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");
var style2 = ImGui.GetStyle();
float BtnWidth2(string label) => ImGui.CalcTextSize(label).X + style2.FramePadding.X * 2f;
float sharedActionsWidth = BtnWidth2("Appliquer") + style2.ItemSpacing.X + BtnWidth2("Enregistrer") + 2f;
ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.WidthFixed, sharedActionsWidth);
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);
if (ImGui.IsItemHovered())
{
ImGui.BeginTooltip();
ImGui.TextUnformatted($"UID propriétaire: {entry.OwnerUid}");
if (!string.IsNullOrEmpty(entry.OwnerAlias))
{
ImGui.Separator();
ImGui.TextUnformatted($"Alias: {entry.OwnerAlias}");
}
ImGui.EndTooltip();
}
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 FormatUidWithName(string uid)
{
if (string.IsNullOrEmpty(uid)) return string.Empty;
var note = _serverConfigurationManager.GetNoteForUid(uid);
if (!string.IsNullOrEmpty(note)) return $"{uid} ({note})";
return uid;
}
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);

View File

@@ -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<Services.Mediator.NearbyEntry> _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<NotificationStateChanged>(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();
@@ -848,28 +860,34 @@ if (showNearby && pendingInvites > 0)
ImGuiHelpers.ScaledDummy(4f);
var indent = 18f * ImGuiHelpers.GlobalScale;
ImGui.Indent(indent);
foreach (var e in nearbyEntries)
// Use a table to guarantee right-aligned action within the card content area
var actionButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.UserPlus);
if (ImGui.BeginTable("nearby-table", 2, ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.RowBg | ImGuiTableFlags.PadOuterX | ImGuiTableFlags.BordersInnerV))
{
if (!e.AcceptPairRequests || string.IsNullOrEmpty(e.Token))
{
continue;
}
ImGui.TableSetupColumn("Name", ImGuiTableColumnFlags.WidthStretch, 1f);
ImGui.TableSetupColumn("Action", ImGuiTableColumnFlags.WidthFixed, actionButtonSize.X);
var name = e.DisplayName ?? e.Name;
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted(name);
var right = ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth();
ImGui.SameLine();
foreach (var e in nearbyEntries)
{
if (!e.AcceptPairRequests || string.IsNullOrEmpty(e.Token))
{
continue;
}
ImGui.TableNextRow();
ImGui.TableSetColumnIndex(0);
var name = e.DisplayName ?? e.Name;
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted(name);
// Right column: action button, aligned to the right within the column
ImGui.TableSetColumnIndex(1);
var curX = ImGui.GetCursorPosX();
var availX = ImGui.GetContentRegionAvail().X; // width of the action column
ImGui.SetCursorPosX(curX + MathF.Max(0, availX - actionButtonSize.X));
var statusButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.UserPlus);
ImGui.SetCursorPosX(right - statusButtonSize.X);
if (!e.AcceptPairRequests)
{
_uiSharedService.IconText(FontAwesomeIcon.Ban, ImGuiColors.DalamudGrey3);
UiSharedService.AttachToolTip("Les demandes sont désactivées pour ce joueur");
}
else if (!string.IsNullOrEmpty(e.Token))
{
using (ImRaii.PushId(e.Token ?? e.Uid ?? e.Name ?? string.Empty))
{
if (_uiSharedService.IconButton(FontAwesomeIcon.UserPlus))
@@ -879,12 +897,9 @@ if (showNearby && pendingInvites > 0)
}
UiSharedService.AttachToolTip("Envoyer une invitation d'apparaige");
}
else
{
_uiSharedService.IconText(FontAwesomeIcon.QuestionCircle, ImGuiColors.DalamudGrey3);
UiSharedService.AttachToolTip("Impossible d'inviter ce joueur");
}
ImGui.EndTable();
}
ImGui.Unindent(indent);
}, stretchWidth: true);
}
@@ -898,6 +913,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 +929,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 +941,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 +951,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 +981,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 +999,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 +1032,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 +1109,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 +1121,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 +1160,125 @@ 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;
case NotificationCategory.Syncshell:
DrawSyncshellNotification(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 DrawSyncshellNotification(NotificationEntry notification)
{
UiSharedService.DrawCard($"notification-syncshell-{notification.Id}", () =>
{
ImGui.TextUnformatted(notification.Title);
if (!string.IsNullOrEmpty(notification.Description))
{
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3);
ImGui.TextWrapped(notification.Description);
ImGui.PopStyleColor();
}
ImGuiHelpers.ScaledDummy(3f);
using (ImRaii.PushId($"syncshell-{notification.Id}"))
{
if (ImGui.Button("Effacer"))
{
_notificationTracker.Remove(NotificationCategory.Syncshell, 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 +1317,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 +1437,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)

View File

@@ -79,6 +79,8 @@ public class DrawGroupPair : DrawPairBase
width += _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Plus).X + spacing;
}
width += spacing * 1.2f;
return width;
}
@@ -215,6 +217,7 @@ public class DrawGroupPair : DrawPairBase
var pauseIcon = _fullInfoDto.GroupUserPermissions.IsPaused() ? FontAwesomeIcon.Play : FontAwesomeIcon.Pause;
var pauseButtonWidth = _uiSharedService.GetIconButtonSize(pauseIcon).X;
var barButtonWidth = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Bars).X;
var rightEdgeGap = spacing * 1.2f;
float totalWidth = 0f;
void Accumulate(bool condition, float width)
@@ -242,7 +245,7 @@ public class DrawGroupPair : DrawPairBase
float cardPaddingX = UiSharedService.GetCardContentPaddingX();
float rightMargin = cardPaddingX + 6f * ImGuiHelpers.GlobalScale;
float baseX = MathF.Max(ImGui.GetCursorPosX(),
ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth() - rightMargin - totalWidth);
ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth() - rightMargin - rightEdgeGap - totalWidth);
float currentX = baseX;
ImGui.SameLine();
@@ -266,6 +269,16 @@ public class DrawGroupPair : DrawPairBase
if (showInfo && infoIconWidth > 0f)
{
bool centerWarning = permIcon == FontAwesomeIcon.ExclamationTriangle && showPause && showBars && !showShared && !showPlus;
if (centerWarning)
{
float barsClusterWidth = showBars ? (barButtonWidth + spacing * 0.5f) : 0f;
float leftAreaWidth = MathF.Max(totalWidth - pauseButtonWidth - barsClusterWidth, 0f);
float warningX = baseX + MathF.Max((leftAreaWidth - infoIconWidth) / 2f, 0f);
currentX = warningX;
ImGui.SetCursorPosX(currentX);
}
ImGui.SetCursorPosY(textPosY);
if (individualAnimDisabled || individualSoundsDisabled || individualVFXDisabled)
{
@@ -359,7 +372,7 @@ public class DrawGroupPair : DrawPairBase
{
ImGui.SetCursorPosY(originalY);
if (_uiSharedService.IconButton(FontAwesomeIcon.Plus))
if (_uiSharedService.IconPlusButtonCentered())
{
var targetUid = _pair.UserData.UID;
if (!string.IsNullOrEmpty(targetUid))
@@ -376,7 +389,7 @@ public class DrawGroupPair : DrawPairBase
{
float gapToBars = showBars ? spacing * 0.5f : spacing;
ImGui.SetCursorPosY(originalY);
if (_uiSharedService.IconButton(pauseIcon))
if (pauseIcon == FontAwesomeIcon.Pause ? _uiSharedService.IconPauseButtonCentered() : _uiSharedService.IconButtonCentered(pauseIcon))
{
var newPermissions = _fullInfoDto.GroupUserPermissions ^ GroupUserPermissions.Paused;
_fullInfoDto.GroupUserPermissions = newPermissions;
@@ -391,7 +404,7 @@ public class DrawGroupPair : DrawPairBase
if (showBars)
{
ImGui.SetCursorPosY(originalY);
if (_uiSharedService.IconButton(FontAwesomeIcon.Bars))
if (_uiSharedService.IconButtonCentered(FontAwesomeIcon.Bars))
{
ImGui.OpenPopup("Syncshell Flyout Menu");
}

View File

@@ -42,8 +42,8 @@ public abstract class DrawPairBase
var menuButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Bars);
float pauseClusterWidth = Math.Max(pauseButtonSize.X, playButtonSize.X);
float pauseClusterHeight = Math.Max(pauseButtonSize.Y, playButtonSize.Y);
float reservedSpacing = style.ItemSpacing.X * 2.4f;
float pauseClusterHeight = Math.Max(Math.Max(pauseButtonSize.Y, playButtonSize.Y), ImGui.GetFrameHeight());
float reservedSpacing = style.ItemSpacing.X * 1.6f;
float rightButtonWidth =
menuButtonSize.X +
pauseClusterWidth +
@@ -84,11 +84,15 @@ public abstract class DrawPairBase
ImGui.SetCursorPos(new Vector2(rowStartCursor.X + padding.X, iconTop));
DrawLeftSide(iconTop, iconTop);
ImGui.SameLine();
ImGui.SetCursorPosY(textTop);
var posX = ImGui.GetCursorPosX();
float leftReserved = GetLeftSideReservedWidth();
float nameStartX = rowStartCursor.X + padding.X + leftReserved;
var rightSide = DrawRightSide(buttonTop, buttonTop);
DrawName(textTop + padding.Y * 0.15f, posX, rightSide);
ImGui.SameLine(nameStartX);
ImGui.SetCursorPosY(textTop);
DrawName(textTop + padding.Y * 0.15f, nameStartX, rightSide);
ImGui.SetCursorPos(new Vector2(rowStartCursor.X, rowStartCursor.Y + totalHeight));
ImGui.SetCursorPosX(rowStartCursor.X);
@@ -100,6 +104,8 @@ public abstract class DrawPairBase
protected virtual float GetRightSideExtraWidth() => 0f;
protected virtual float GetLeftSideReservedWidth() => UiSharedService.GetIconSize(FontAwesomeIcon.Moon).X * 2f + ImGui.GetStyle().ItemSpacing.X * 1.5f;
private void DrawName(float originalY, float leftSide, float rightSide)
{
_displayHandler.DrawPairText(_id, _pair, leftSide, originalY, () => rightSide - leftSide);

View File

@@ -60,9 +60,29 @@ public class DrawUserPair : DrawPairBase
width += _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Running).X + spacingX * 0.5f;
}
width += spacingX * 1.2f;
return width;
}
protected override float GetLeftSideReservedWidth()
{
var style = ImGui.GetStyle();
float spacing = style.ItemSpacing.X;
float iconW = UiSharedService.GetIconSize(FontAwesomeIcon.Moon).X;
int icons = 1;
if (!(_pair.UserPair!.OwnPermissions.IsPaired() && _pair.UserPair!.OtherPermissions.IsPaired()))
icons++;
else if (_pair.UserPair!.OwnPermissions.IsPaused() || _pair.UserPair!.OtherPermissions.IsPaused())
icons++;
if (_pair.IsOnline && _pair.IsVisible)
icons++;
float iconsTotal = icons * iconW + Math.Max(0, icons - 1) * spacing;
float cushion = spacing * 0.6f;
return iconsTotal + cushion;
}
protected override void DrawLeftSide(float textPosY, float originalY)
{
var online = _pair.IsOnline;
@@ -133,7 +153,8 @@ public class DrawUserPair : DrawPairBase
var entryUID = _pair.UserData.AliasOrUID;
var spacingX = ImGui.GetStyle().ItemSpacing.X;
var edgePadding = UiSharedService.GetCardContentPaddingX() + 6f * ImGuiHelpers.GlobalScale;
var windowEndX = ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth() - edgePadding;
var rightEdgeGap = spacingX * 1.2f;
var windowEndX = ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth() - edgePadding - rightEdgeGap;
var rightSidePos = windowEndX - barButtonSize.X;
// Flyout Menu
@@ -150,13 +171,12 @@ public class DrawUserPair : DrawPairBase
ImGui.EndPopup();
}
// Pause (mutual pairs only)
if (_pair.UserPair!.OwnPermissions.IsPaired() && _pair.UserPair!.OtherPermissions.IsPaired())
{
rightSidePos -= pauseIconSize.X + spacingX;
ImGui.SameLine(rightSidePos);
ImGui.SetCursorPosY(originalY);
if (_uiSharedService.IconButton(pauseIcon))
if (pauseIcon == FontAwesomeIcon.Pause ? _uiSharedService.IconPauseButtonCentered() : _uiSharedService.IconButtonCentered(pauseIcon))
{
var perm = _pair.UserPair!.OwnPermissions;
perm.SetPaused(!perm.IsPaused());

View File

@@ -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<string, CharacterAnalyzer.FileDataEntry>? 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<string, CharacterAnalyzer.FileDataEntry>? 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))

View File

@@ -52,6 +52,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
private readonly AccountRegistrationService _registerService;
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly UiSharedService _uiShared;
private readonly TypingIndicatorStateService _typingStateService;
private readonly ChatTypingDetectionService _chatTypingDetectionService;
private static readonly string DtrDefaultPreviewText = DtrEntry.DefaultGlyph + " 123";
private bool _deleteAccountPopupModalShown = false;
private string _lastTab = string.Empty;
@@ -80,7 +82,9 @@ public class SettingsUi : WindowMediatorSubscriberBase
FileCompactor fileCompactor, ApiController apiController,
IpcManager ipcManager, IpcProvider ipcProvider, CacheMonitor cacheMonitor,
DalamudUtilService dalamudUtilService, AccountRegistrationService registerService,
AutoDetectSuppressionService autoDetectSuppressionService) : base(logger, mediator, "Umbra Settings", performanceCollector)
AutoDetectSuppressionService autoDetectSuppressionService,
TypingIndicatorStateService typingIndicatorStateService,
ChatTypingDetectionService chatTypingDetectionService) : base(logger, mediator, "Umbra Settings", performanceCollector)
{
_configService = configService;
_pairManager = pairManager;
@@ -102,6 +106,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
_autoDetectSuppressionService = autoDetectSuppressionService;
_fileCompactor = fileCompactor;
_uiShared = uiShared;
_typingStateService = typingIndicatorStateService;
_chatTypingDetectionService = chatTypingDetectionService;
AllowClickthrough = false;
AllowPinning = false;
_validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v);
@@ -1123,8 +1129,10 @@ public class SettingsUi : WindowMediatorSubscriberBase
var useNameColors = _configService.Current.UseNameColors;
var nameColors = _configService.Current.NameColors;
var autoPausedNameColors = _configService.Current.BlockedNameColors;
var typingEnabled = _configService.Current.TypingIndicatorEnabled;
var typingIndicatorNameplates = _configService.Current.TypingIndicatorShowOnNameplates;
var typingIndicatorPartyList = _configService.Current.TypingIndicatorShowOnPartyList;
var typingShowSelf = _configService.Current.TypingIndicatorShowSelf;
if (ImGui.Checkbox("Coloriser les plaques de nom des paires", ref useNameColors))
{
_configService.Current.UseNameColors = useNameColors;
@@ -1152,42 +1160,60 @@ public class SettingsUi : WindowMediatorSubscriberBase
}
}
if (ImGui.Checkbox("Afficher la bulle de frappe sur les plaques", ref typingIndicatorNameplates))
if (ImGui.Checkbox("Activer le système d'indicateur de frappe", ref typingEnabled))
{
_configService.Current.TypingIndicatorShowOnNameplates = typingIndicatorNameplates;
_configService.Current.TypingIndicatorEnabled = typingEnabled;
_configService.Save();
_chatTypingDetectionService.SoftRestart();
}
_uiShared.DrawHelpText("Ajoute une bulle '...' sur la plaque des paires en train d'écrire.");
_uiShared.DrawHelpText("Active ou désactive complètement l'envoi/la réception et l'affichage des bulles de frappe.");
using (ImRaii.Disabled(!typingIndicatorNameplates))
if (typingEnabled)
{
using var indentTyping = ImRaii.PushIndent();
var bubbleSize = _configService.Current.TypingIndicatorBubbleSize;
TypingIndicatorBubbleSize? selectedBubbleSize = _uiShared.DrawCombo("Taille de la bulle de frappe##typingBubbleSize",
Enum.GetValues<TypingIndicatorBubbleSize>(),
size => size switch
{
TypingIndicatorBubbleSize.Small => "Petite",
TypingIndicatorBubbleSize.Medium => "Moyenne",
TypingIndicatorBubbleSize.Large => "Grande",
_ => size.ToString()
},
null,
bubbleSize);
if (selectedBubbleSize.HasValue && selectedBubbleSize.Value != bubbleSize)
if (ImGui.Checkbox("Afficher la bulle de frappe sur les plaques", ref typingIndicatorNameplates))
{
_configService.Current.TypingIndicatorBubbleSize = selectedBubbleSize.Value;
_configService.Current.TypingIndicatorShowOnNameplates = typingIndicatorNameplates;
_configService.Save();
}
}
_uiShared.DrawHelpText("Ajoute une bulle '...' sur la plaque des paires en train d'écrire.");
if (ImGui.Checkbox("Tracer la frappe dans la liste de groupe", ref typingIndicatorPartyList))
{
_configService.Current.TypingIndicatorShowOnPartyList = typingIndicatorPartyList;
_configService.Save();
if (typingIndicatorNameplates)
{
using var indentTyping = ImRaii.PushIndent();
var bubbleSize = _configService.Current.TypingIndicatorBubbleSize;
TypingIndicatorBubbleSize? selectedBubbleSize = _uiShared.DrawCombo("Taille de la bulle de frappe##typingBubbleSize",
Enum.GetValues<TypingIndicatorBubbleSize>(),
size => size switch
{
TypingIndicatorBubbleSize.Small => "Petite",
TypingIndicatorBubbleSize.Medium => "Moyenne",
TypingIndicatorBubbleSize.Large => "Grande",
_ => size.ToString()
},
null,
bubbleSize);
if (selectedBubbleSize.HasValue && selectedBubbleSize.Value != bubbleSize)
{
_configService.Current.TypingIndicatorBubbleSize = selectedBubbleSize.Value;
_configService.Save();
}
if (ImGui.Checkbox("Tracer la frappe dans la liste de groupe", ref typingIndicatorPartyList))
{
_configService.Current.TypingIndicatorShowOnPartyList = typingIndicatorPartyList;
_configService.Save();
}
_uiShared.DrawHelpText("Consigne dans les journaux quand une paire du groupe est en train d'écrire (bulle visuelle ultérieure).");
if (ImGui.Checkbox("Afficher ma propre bulle", ref typingShowSelf))
{
_configService.Current.TypingIndicatorShowSelf = typingShowSelf;
_configService.Save();
}
_uiShared.DrawHelpText("Affiche votre propre bulle lorsque vous tapez (utile pour test/retour visuel).");
}
}
_uiShared.DrawHelpText("Consigne dans les journaux quand une paire du groupe est en train d'écrire (bulle visuelle ultérieure).");
if (ImGui.Checkbox("Show separate Visible group", ref showVisibleSeparate))
{

View File

@@ -3,15 +3,21 @@ 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.Services.Notifications;
using MareSynchronos.WebAPI;
using Microsoft.Extensions.Logging;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using MareSynchronos.MareConfiguration.Models;
namespace MareSynchronos.UI.Components.Popup;
@@ -23,6 +29,8 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
private readonly List<string> _oneTimeInvites = [];
private readonly PairManager _pairManager;
private readonly UiSharedService _uiSharedService;
private readonly SyncshellDiscoveryService _syncshellDiscoveryService;
private readonly NotificationTracker _notificationTracker;
private List<BannedGroupUserDto> _bannedUsers = [];
private int _multiInvites;
private string _newPassword;
@@ -30,20 +38,43 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
private Task<int>? _pruneTestTask;
private Task<int>? _pruneTask;
private int _pruneDays = 14;
private bool _autoDetectStateInitialized;
private bool _autoDetectStateLoading;
private bool _autoDetectToggleInFlight;
private bool _autoDetectVisible;
private bool _autoDetectPasswordDisabled;
private string? _autoDetectMessage;
private bool _autoDetectDesiredVisibility;
private int _adDurationHours = 2;
private bool _adRecurring = false;
private readonly bool[] _adWeekdays = new bool[7];
private int _adStartHour = 21;
private int _adStartMinute = 0;
private int _adEndHour = 23;
private int _adEndMinute = 0;
private const string AutoDetectTimeZone = "Europe/Paris";
public SyncshellAdminUI(ILogger<SyncshellAdminUI> logger, MareMediator mediator, ApiController apiController,
UiSharedService uiSharedService, PairManager pairManager, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService)
UiSharedService uiSharedService, PairManager pairManager, SyncshellDiscoveryService syncshellDiscoveryService,
GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, NotificationTracker notificationTracker)
: base(logger, mediator, "Syncshell Admin Panel (" + groupFullInfo.GroupAliasOrGID + ")", performanceCollectorService)
{
GroupFullInfo = groupFullInfo;
_apiController = apiController;
_uiSharedService = uiSharedService;
_pairManager = pairManager;
_syncshellDiscoveryService = syncshellDiscoveryService;
_notificationTracker = notificationTracker;
_isOwner = string.Equals(GroupFullInfo.OwnerUID, _apiController.UID, StringComparison.Ordinal);
_isModerator = GroupFullInfo.GroupUserInfo.IsModerator();
_newPassword = string.Empty;
_multiInvites = 30;
_pwChangeSuccess = true;
_autoDetectVisible = groupFullInfo.AutoDetectVisible;
_autoDetectDesiredVisibility = _autoDetectVisible;
_autoDetectPasswordDisabled = groupFullInfo.PasswordTemporarilyDisabled;
Mediator.Subscribe<SyncshellAutoDetectStateChanged>(this, OnSyncshellAutoDetectStateChanged);
IsOpen = true;
SizeConstraints = new WindowSizeConstraints()
{
@@ -59,6 +90,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);
@@ -68,6 +104,9 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
ImGui.Separator();
var perm = GroupFullInfo.GroupPermissions;
using var tabColor = ImRaii.PushColor(ImGuiCol.Tab, UiSharedService.AccentColor);
using var tabHoverColor = ImRaii.PushColor(ImGuiCol.TabHovered, UiSharedService.AccentHoverColor);
using var tabActiveColor = ImRaii.PushColor(ImGuiCol.TabActive, UiSharedService.AccentActiveColor);
using var tabbar = ImRaii.TabBar("syncshell_tab_" + GroupFullInfo.GID);
if (tabbar)
@@ -363,6 +402,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,8 +494,283 @@ 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);
}
using (ImRaii.Disabled(_autoDetectToggleInFlight || _autoDetectStateLoading))
{
if (ImGui.Checkbox("Afficher cette Syncshell dans l'AutoDetect", ref _autoDetectDesiredVisibility))
{
// Only change local desired state; sending is done via the validate button
}
}
_uiSharedService.DrawHelpText("Quand cette option est activée, le mot de passe devient inactif tant que la visibilité est maintenue.");
if (_autoDetectDesiredVisibility)
{
ImGuiHelpers.ScaledDummy(4);
ImGui.TextUnformatted("Options d'affichage AutoDetect");
ImGui.Separator();
// Recurring toggle first
ImGui.Checkbox("Affichage récurrent", ref _adRecurring);
_uiSharedService.DrawHelpText("Si activé, vous pouvez choisir les jours et une plage horaire récurrents. Si désactivé, seule la durée sera prise en compte.");
// Duration in hours (only when NOT recurring)
if (!_adRecurring)
{
ImGuiHelpers.ScaledDummy(4);
int duration = _adDurationHours;
ImGui.PushItemWidth(120 * ImGuiHelpers.GlobalScale);
if (ImGui.InputInt("Durée (heures)", ref duration))
{
_adDurationHours = Math.Clamp(duration, 1, 240);
}
ImGui.PopItemWidth();
_uiSharedService.DrawHelpText("Combien de temps la Syncshell doit rester visible, en heures.");
}
ImGuiHelpers.ScaledDummy(4);
if (_adRecurring)
{
ImGuiHelpers.ScaledDummy(4);
ImGui.TextUnformatted("Jours de la semaine actifs :");
string[] daysFr = new[] { "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim" };
for (int i = 0; i < 7; i++)
{
ImGui.SameLine(i == 0 ? 0 : 0);
bool v = _adWeekdays[i];
if (ImGui.Checkbox($"##adwd{i}", ref v)) _adWeekdays[i] = v;
ImGui.SameLine();
ImGui.TextUnformatted(daysFr[i]);
if (i < 6) ImGui.SameLine();
}
ImGui.NewLine();
_uiSharedService.DrawHelpText("Sélectionnez les jours où l'affichage est autorisé (ex: jeudi et dimanche).");
ImGuiHelpers.ScaledDummy(4);
ImGui.TextUnformatted("Plage horaire (heure locale Europe/Paris) :");
ImGui.PushItemWidth(60 * ImGuiHelpers.GlobalScale);
ImGui.InputInt("Début heure", ref _adStartHour); ImGui.SameLine();
ImGui.InputInt("min", ref _adStartMinute);
_adStartHour = Math.Clamp(_adStartHour, 0, 23);
_adStartMinute = Math.Clamp(_adStartMinute, 0, 59);
ImGui.SameLine();
ImGui.TextUnformatted("→"); ImGui.SameLine();
ImGui.InputInt("Fin heure", ref _adEndHour); ImGui.SameLine();
ImGui.InputInt("min ", ref _adEndMinute);
_adEndHour = Math.Clamp(_adEndHour, 0, 23);
_adEndMinute = Math.Clamp(_adEndMinute, 0, 59);
ImGui.PopItemWidth();
_uiSharedService.DrawHelpText("Exemple : de 21h00 à 23h00. Le fuseau utilisé est Europe/Paris (avec changements été/hiver).");
}
}
if (_autoDetectPasswordDisabled && _autoDetectVisible)
{
UiSharedService.ColorTextWrapped("Le mot de passe est actuellement désactivé pendant la visibilité AutoDetect.", ImGuiColors.DalamudYellow);
}
ImGuiHelpers.ScaledDummy(6);
using (ImRaii.Disabled(_autoDetectToggleInFlight || _autoDetectStateLoading))
{
if (ImGui.Button("Valider et envoyer"))
{
_ = SubmitAutoDetectAsync();
}
ImGui.SameLine();
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.";
if (desiredVisibility)
{
PublishSyncshellPublicNotification();
}
}
catch (Exception ex)
{
_autoDetectMessage = $"Erreur lors de la mise à jour AutoDetect : {ex.Message}";
}
finally
{
_autoDetectToggleInFlight = false;
}
}
private async Task SubmitAutoDetectAsync()
{
if (_autoDetectToggleInFlight)
{
return;
}
_autoDetectToggleInFlight = true;
_autoDetectMessage = null;
try
{
// Duration always used when visible
int? duration = _autoDetectDesiredVisibility ? _adDurationHours : null;
// Scheduling fields only if recurring is enabled
int[]? weekdaysArr = null;
TimeSpan? start = null;
TimeSpan? end = null;
string? tz = null;
if (_autoDetectDesiredVisibility && _adRecurring)
{
List<int> weekdays = new();
for (int i = 0; i < 7; i++) if (_adWeekdays[i]) weekdays.Add(i);
weekdaysArr = weekdays.Count > 0 ? weekdays.ToArray() : Array.Empty<int>();
start = new TimeSpan(_adStartHour, _adStartMinute, 0);
end = new TimeSpan(_adEndHour, _adEndMinute, 0);
tz = AutoDetectTimeZone;
}
var ok = await _syncshellDiscoveryService.SetVisibilityAsync(
GroupFullInfo.GID,
_autoDetectDesiredVisibility,
duration,
weekdaysArr,
start,
end,
tz,
CancellationToken.None).ConfigureAwait(false);
if (!ok)
{
_autoDetectMessage = "Impossible d'envoyer les paramètres AutoDetect.";
return;
}
await EnsureAutoDetectStateAsync(true).ConfigureAwait(false);
_autoDetectMessage = _autoDetectDesiredVisibility
? "Paramètres AutoDetect envoyés. La Syncshell sera visible selon le planning défini."
: "La Syncshell n'est plus visible dans AutoDetect.";
if (_autoDetectDesiredVisibility)
{
PublishSyncshellPublicNotification();
}
}
catch (Exception ex)
{
_autoDetectMessage = $"Erreur lors de l'envoi des paramètres 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));
}
private void PublishSyncshellPublicNotification()
{
try
{
var title = $"Syncshell publique: {GroupFullInfo.GroupAliasOrGID}";
var message = "La Syncshell est désormais visible via AutoDetect.";
Mediator.Publish(new DualNotificationMessage(title, message, NotificationType.Info, TimeSpan.FromSeconds(4)));
_notificationTracker.Upsert(NotificationEntry.SyncshellPublic(GroupFullInfo.GID, GroupFullInfo.GroupAliasOrGID));
}
catch
{
// swallow any notification errors to not break UI flow
}
}
}

View File

@@ -7,6 +7,7 @@ using Dalamud.Interface.Utility;
using Dalamud.Plugin.Services;
using MareSynchronos.API.Data;
using MareSynchronos.API.Data.Extensions;
using MareSynchronos.API.Data.Enum;
using MareSynchronos.MareConfiguration;
using MareSynchronos.MareConfiguration.Models;
using MareSynchronos.PlayerData.Pairs;
@@ -75,6 +76,10 @@ public sealed class TypingIndicatorOverlay : WindowMediatorSubscriberBase
if (!_clientState.IsLoggedIn)
return;
var typingEnabled = _configService.Current.TypingIndicatorEnabled;
if (!typingEnabled)
return;
var showParty = _configService.Current.TypingIndicatorShowOnPartyList;
var showNameplates = _configService.Current.TypingIndicatorShowOnNameplates;
@@ -97,14 +102,16 @@ public sealed class TypingIndicatorOverlay : WindowMediatorSubscriberBase
}
}
private unsafe void DrawPartyIndicators(ImDrawListPtr drawList, IReadOnlyDictionary<string, (UserData User, DateTime FirstSeen, DateTime LastUpdate)> activeTypers,
private unsafe void DrawPartyIndicators(ImDrawListPtr drawList, IReadOnlyDictionary<string, (UserData User, DateTime FirstSeen, DateTime LastUpdate, MareSynchronos.API.Data.Enum.TypingScope Scope)> activeTypers,
bool selfActive, DateTime now, DateTime selfStart, DateTime selfLast)
{
var partyAddon = (AtkUnitBase*)_gameGui.GetAddonByName("_PartyList", 1).Address;
if (partyAddon == null || !partyAddon->IsVisible)
return;
var showSelf = _configService.Current.TypingIndicatorShowSelf;
if (selfActive
&& showSelf
&& (now - selfStart) >= TypingDisplayDelay
&& (now - selfLast) <= TypingDisplayFade)
{
@@ -180,14 +187,16 @@ public sealed class TypingIndicatorOverlay : WindowMediatorSubscriberBase
ImGui.ColorConvertFloat4ToU32(new Vector4(1f, 1f, 1f, 0.9f)));
}
private unsafe void DrawNameplateIndicators(ImDrawListPtr drawList, IReadOnlyDictionary<string, (UserData User, DateTime FirstSeen, DateTime LastUpdate)> activeTypers,
private unsafe void DrawNameplateIndicators(ImDrawListPtr drawList, IReadOnlyDictionary<string, (UserData User, DateTime FirstSeen, DateTime LastUpdate, MareSynchronos.API.Data.Enum.TypingScope Scope)> activeTypers,
bool selfActive, DateTime now, DateTime selfStart, DateTime selfLast)
{
var iconWrap = _textureProvider.GetFromGameIcon(NameplateIconId).GetWrapOrEmpty();
if (iconWrap == null || iconWrap.Handle == IntPtr.Zero)
return;
var showSelf = _configService.Current.TypingIndicatorShowSelf;
if (selfActive
&& showSelf
&& _clientState.LocalPlayer != null
&& (now - selfStart) >= TypingDisplayDelay
&& (now - selfLast) <= TypingDisplayFade)
@@ -212,11 +221,22 @@ public sealed class TypingIndicatorOverlay : WindowMediatorSubscriberBase
var pairName = pair?.PlayerName ?? entry.User.AliasOrUID ?? string.Empty;
var pairIdent = pair?.Ident ?? string.Empty;
var isPartyMember = IsPartyMember(objectId, pairName);
// Enforce party-only visibility when the scope is Party/CrossParty
if (entry.Scope is TypingScope.Party or TypingScope.CrossParty)
{
if (!isPartyMember)
{
_typedLogger.LogTrace("TypingIndicator: suppressed non-party bubble for {uid} due to scope={scope}", uid, entry.Scope);
continue;
}
}
var isRelevantMember = IsPlayerRelevant(pair, isPartyMember);
if (objectId != uint.MaxValue && objectId != 0 && TryDrawNameplateBubble(drawList, iconWrap, objectId))
{
_typedLogger.LogTrace("TypingIndicator: drew nameplate bubble for {uid} (objectId={objectId})", uid, objectId);
_typedLogger.LogTrace("TypingIndicator: drew nameplate bubble for {uid} (objectId={objectId}, scope={scope})", uid, objectId, entry.Scope);
continue;
}
@@ -226,13 +246,20 @@ public sealed class TypingIndicatorOverlay : WindowMediatorSubscriberBase
if (!isRelevantMember && !isNearby)
continue;
// For Party/CrossParty scope, do not draw fallback world icon for non-party even if nearby
if (entry.Scope is TypingScope.Party or TypingScope.CrossParty)
{
if (!isPartyMember)
continue;
}
if (pair == null)
{
_typedLogger.LogTrace("TypingIndicator: no pair found for {uid}, attempting fallback", uid);
}
_typedLogger.LogTrace("TypingIndicator: fallback draw for {uid} (objectId={objectId}, name={name}, ident={ident})",
uid, objectId, pairName, pairIdent);
_typedLogger.LogTrace("TypingIndicator: fallback draw for {uid} (objectId={objectId}, name={name}, ident={ident}, scope={scope})",
uid, objectId, pairName, pairIdent, entry.Scope);
if (hasWorldPosition)
{
@@ -318,26 +345,6 @@ public sealed class TypingIndicatorOverlay : WindowMediatorSubscriberBase
if (!iconVisible)
{
sizeScaleFactor = 2.5f;
var anchor = rootPosition + iconLocalPosition + new Vector2(iconDimensions.X * 0.5f, 0f);
var distanceOffset = new Vector2(0f, -16f + distance) * scaleVector;
if (iconNode->Height == 24)
{
distanceOffset.Y += 16f * scaleY;
}
distanceOffset.Y += 64f * scaleY;
var referenceSize = GetConfiguredBubbleSize(scaleX * sizeScaleFactor, scaleY * sizeScaleFactor, false, TypingIndicatorBubbleSize.Small);
var manualOffset = new Vector2(referenceSize.X * 2.00f, referenceSize.Y * 2.00f);
var iconSizeHidden = GetConfiguredBubbleSize(scaleX * sizeScaleFactor, scaleY * sizeScaleFactor, false);
var center = anchor + distanceOffset + manualOffset;
var topLeft = center - (iconSizeHidden / 2f);
drawList.AddImage(textureWrap.Handle, topLeft, topLeft + iconSizeHidden, Vector2.Zero, Vector2.One,
ImGui.ColorConvertFloat4ToU32(new Vector4(1f, 1f, 1f, 0.95f)));
return true;
}

View File

@@ -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));
@@ -538,6 +542,100 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
return result;
}
public bool IconButtonCentered(FontAwesomeIcon icon, float? height = null, float xOffset = 0f, float yOffset = 0f, bool square = false)
{
string text = icon.ToIconString();
ImGui.PushID($"centered-{text}");
Vector2 glyphSize;
using (IconFont.Push())
glyphSize = ImGui.CalcTextSize(text);
ImDrawListPtr drawList = ImGui.GetWindowDrawList();
Vector2 cursorScreenPos = ImGui.GetCursorScreenPos();
float frameHeight = height ?? ImGui.GetFrameHeight();
float buttonWidth = square ? frameHeight : glyphSize.X + ImGui.GetStyle().FramePadding.X * 2f;
using var hoverColor = ImRaii.PushColor(ImGuiCol.ButtonHovered, AccentHoverColor);
using var activeColor = ImRaii.PushColor(ImGuiCol.ButtonActive, AccentActiveColor);
bool clicked = ImGui.Button(string.Empty, new Vector2(buttonWidth, frameHeight));
Vector2 pos = new Vector2(
cursorScreenPos.X + (buttonWidth - glyphSize.X) / 2f + xOffset,
cursorScreenPos.Y + frameHeight / 2f - glyphSize.Y / 2f + yOffset);
using (IconFont.Push())
drawList.AddText(pos, ImGui.GetColorU32(ImGuiCol.Text), text);
ImGui.PopID();
return clicked;
}
public bool IconPauseButtonCentered(float? height = null)
{
ImGui.PushID("centered-pause-custom");
Vector2 glyphSize;
using (IconFont.Push())
glyphSize = ImGui.CalcTextSize(FontAwesomeIcon.Pause.ToIconString());
float frameHeight = height ?? ImGui.GetFrameHeight();
float buttonWidth = glyphSize.X + ImGui.GetStyle().FramePadding.X * 2f;
using var hoverColor = ImRaii.PushColor(ImGuiCol.ButtonHovered, AccentHoverColor);
using var activeColor = ImRaii.PushColor(ImGuiCol.ButtonActive, AccentActiveColor);
var drawList = ImGui.GetWindowDrawList();
var buttonTopLeft = ImGui.GetCursorScreenPos();
bool clicked = ImGui.Button(string.Empty, new Vector2(buttonWidth, frameHeight));
var textColor = ImGui.GetColorU32(ImGuiCol.Text);
float h = frameHeight * 0.55f; // bar height
float w = MathF.Max(1f, frameHeight * 0.16f); // bar width
float gap = MathF.Max(1f, w * 0.9f); // gap between bars
float total = 2f * w + gap;
float startX = buttonTopLeft.X + (buttonWidth - total) / 2f;
float startY = buttonTopLeft.Y + (frameHeight - h) / 2f;
float rounding = w * 0.35f;
drawList.AddRectFilled(new Vector2(startX, startY), new Vector2(startX + w, startY + h), textColor, rounding);
float rightX = startX + w + gap;
drawList.AddRectFilled(new Vector2(rightX, startY), new Vector2(rightX + w, startY + h), textColor, rounding);
ImGui.PopID();
return clicked;
}
public bool IconPlusButtonCentered(float? height = null)
{
ImGui.PushID("centered-plus-custom");
Vector2 glyphSize;
using (IconFont.Push())
glyphSize = ImGui.CalcTextSize(FontAwesomeIcon.Plus.ToIconString());
float frameHeight = height ?? ImGui.GetFrameHeight();
float buttonWidth = glyphSize.X + ImGui.GetStyle().FramePadding.X * 2f;
using var hoverColor = ImRaii.PushColor(ImGuiCol.ButtonHovered, AccentHoverColor);
using var activeColor = ImRaii.PushColor(ImGuiCol.ButtonActive, AccentActiveColor);
var drawList = ImGui.GetWindowDrawList();
var buttonTopLeft = ImGui.GetCursorScreenPos();
bool clicked = ImGui.Button(string.Empty, new Vector2(buttonWidth, frameHeight));
var color = ImGui.GetColorU32(ImGuiCol.Text);
float armThickness = MathF.Max(1f, frameHeight * 0.14f);
float crossSize = frameHeight * 0.55f; // total length of vertical/horizontal arms
float startX = buttonTopLeft.X + (buttonWidth - crossSize) / 2f;
float startY = buttonTopLeft.Y + (frameHeight - crossSize) / 2f;
float endX = startX + crossSize;
float endY = startY + crossSize;
float r = armThickness * 0.35f;
float hY1 = startY + (crossSize - armThickness) / 2f;
drawList.AddRectFilled(new Vector2(startX, hY1), new Vector2(endX, hY1 + armThickness), color, r);
float vX1 = startX + (crossSize - armThickness) / 2f;
drawList.AddRectFilled(new Vector2(vX1, startY), new Vector2(vX1 + armThickness, endY), color, r);
ImGui.PopID();
return clicked;
}
private bool IconTextButtonInternal(FontAwesomeIcon icon, string text, Vector4? defaultColor = null, float? width = null, bool useAccentHover = true)
{
int colorsPushed = 0;

View File

@@ -103,6 +103,21 @@ public partial class ApiController
await _mareHub!.SendAsync(nameof(UserSetTypingState), isTyping).ConfigureAwait(false);
}
public async Task UserSetTypingState(bool isTyping, MareSynchronos.API.Data.Enum.TypingScope scope)
{
CheckConnection();
try
{
await _mareHub!.SendAsync(nameof(UserSetTypingState), isTyping, scope).ConfigureAwait(false);
}
catch (Exception ex)
{
// fallback for older servers without scope support
Logger.LogDebug(ex, "UserSetTypingState(scope) not supported on server, falling back to legacy call");
await _mareHub!.SendAsync(nameof(UserSetTypingState), isTyping).ConfigureAwait(false);
}
}
private async Task PushCharacterDataInternal(CharacterData character, List<UserData> visibleCharacters)
{
Logger.LogInformation("Pushing character data for {hash} to {charas}", character.DataHash.Value, string.Join(", ", visibleCharacters.Select(c => c.AliasOrUID)));

View File

@@ -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<List<McdfShareEntryDto>> McdfShareGetOwn()
{
if (!IsConnected) return [];
try
{
return await _mareHub!.InvokeAsync<List<McdfShareEntryDto>>(nameof(McdfShareGetOwn)).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Error during {method}", nameof(McdfShareGetOwn));
return [];
}
}
public async Task<List<McdfShareEntryDto>> McdfShareGetShared()
{
if (!IsConnected) return [];
try
{
return await _mareHub!.InvokeAsync<List<McdfShareEntryDto>>(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<McdfSharePayloadDto?> McdfShareDownload(Guid shareId)
{
if (!IsConnected) return null;
try
{
return await _mareHub!.InvokeAsync<McdfSharePayloadDto?>(nameof(McdfShareDownload), shareId).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Error during {method}", nameof(McdfShareDownload));
throw;
}
}
public async Task<bool> McdfShareDelete(Guid shareId)
{
if (!IsConnected) return false;
try
{
return await _mareHub!.InvokeAsync<bool>(nameof(McdfShareDelete), shareId).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Error during {method}", nameof(McdfShareDelete));
throw;
}
}
public async Task<McdfShareEntryDto?> McdfShareUpdate(McdfShareUpdateRequestDto requestDto)
{
if (!IsConnected) return null;
try
{
return await _mareHub!.InvokeAsync<McdfShareEntryDto?>(nameof(McdfShareUpdate), requestDto).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Error during {method}", nameof(McdfShareUpdate));
throw;
}
}
}

View File

@@ -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<List<SyncshellDiscoveryEntryDto>> SyncshellDiscoveryList()
{
CheckConnection();
return await _mareHub!.InvokeAsync<List<SyncshellDiscoveryEntryDto>>(nameof(SyncshellDiscoveryList)).ConfigureAwait(false);
}
public async Task<SyncshellDiscoveryStateDto?> SyncshellDiscoveryGetState(GroupDto group)
{
CheckConnection();
return await _mareHub!.InvokeAsync<SyncshellDiscoveryStateDto?>(nameof(SyncshellDiscoveryGetState), group).ConfigureAwait(false);
}
public async Task<bool> SyncshellDiscoverySetVisibility(SyncshellDiscoveryVisibilityRequestDto request)
{
CheckConnection();
return await _mareHub!.InvokeAsync<bool>(nameof(SyncshellDiscoverySetVisibility), request).ConfigureAwait(false);
}
public async Task<bool> SyncshellDiscoveryJoin(GroupDto group)
{
CheckConnection();
return await _mareHub!.InvokeAsync<bool>(nameof(SyncshellDiscoveryJoin), group).ConfigureAwait(false);
}
}

View File

@@ -3,8 +3,6 @@ using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Services.ServerConfiguration;
using MareSynchronos.WebAPI.SignalR.Utils;
using MessagePack;
using MessagePack.Resolvers;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
@@ -172,25 +170,7 @@ public class HubFactory : MediatorSubscriberBase
options.SkipNegotiation = hubConfig.SkipNegotiation && (transports == HttpTransportType.WebSockets);
options.Transports = transports;
})
.AddMessagePackProtocol(opt =>
{
var resolver = CompositeResolver.Create(StandardResolverAllowPrivate.Instance,
BuiltinResolver.Instance,
AttributeFormatterResolver.Instance,
// replace enum resolver
DynamicEnumAsStringResolver.Instance,
DynamicGenericResolver.Instance,
DynamicUnionResolver.Instance,
DynamicObjectResolver.Instance,
PrimitiveObjectResolver.Instance,
// final fallback(last priority)
StandardResolver.Instance);
opt.SerializerOptions =
MessagePackSerializerOptions.Standard
.WithCompression(MessagePackCompression.Lz4Block)
.WithResolver(resolver);
})
.AddJsonProtocol()
.WithAutomaticReconnect(new ForeverRetryPolicy(Mediator))
.ConfigureLogging(a =>
{