From 699678f6417160f07e3353ea6223f9c812264ca1 Mon Sep 17 00:00:00 2001 From: Keda Date: Sun, 2 Nov 2025 13:48:11 +0100 Subject: [PATCH] =?UTF-8?q?Ajout=20du=20syst=C3=A8me=20de=20notifications?= =?UTF-8?q?=20:=20service,=20configuration=20et=20persistance=20des=20noti?= =?UTF-8?q?fications=20introduits.=20Mise=20=C3=A0=20jour=20de=20l'UI=20po?= =?UTF-8?q?ur=20afficher=20et=20g=C3=A9rer=20les=20notifications=20Syncshe?= =?UTF-8?q?ll.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Configurations/NotificationsConfig.cs | 12 +++ .../Models/NotificationsStore.cs | 14 ++++ .../NotificationsConfigService.cs | 14 ++++ MareSynchronos/Plugin.cs | 2 + .../Notification/NotificationTracker.cs | 73 ++++++++++++++++- .../Services/NotificationService.cs | 35 ++++++++- MareSynchronos/Services/UiFactory.cs | 7 +- MareSynchronos/UI/CompactUI.cs | 78 +++++++++++++------ MareSynchronos/UI/SyncshellAdminUI.cs | 31 +++++++- MareSynchronos/WebAPI/SignalR/HubFactory.cs | 5 +- 10 files changed, 240 insertions(+), 31 deletions(-) create mode 100644 MareSynchronos/MareConfiguration/Configurations/NotificationsConfig.cs create mode 100644 MareSynchronos/MareConfiguration/Models/NotificationsStore.cs create mode 100644 MareSynchronos/MareConfiguration/NotificationsConfigService.cs diff --git a/MareSynchronos/MareConfiguration/Configurations/NotificationsConfig.cs b/MareSynchronos/MareConfiguration/Configurations/NotificationsConfig.cs new file mode 100644 index 0000000..f1a14a1 --- /dev/null +++ b/MareSynchronos/MareConfiguration/Configurations/NotificationsConfig.cs @@ -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 Notifications { get; set; } = new(); + public int Version { get; set; } = 1; +} diff --git a/MareSynchronos/MareConfiguration/Models/NotificationsStore.cs b/MareSynchronos/MareConfiguration/Models/NotificationsStore.cs new file mode 100644 index 0000000..9eb6c5b --- /dev/null +++ b/MareSynchronos/MareConfiguration/Models/NotificationsStore.cs @@ -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; +} diff --git a/MareSynchronos/MareConfiguration/NotificationsConfigService.cs b/MareSynchronos/MareConfiguration/NotificationsConfigService.cs new file mode 100644 index 0000000..9782cd4 --- /dev/null +++ b/MareSynchronos/MareConfiguration/NotificationsConfigService.cs @@ -0,0 +1,14 @@ +using MareSynchronos.MareConfiguration.Configurations; + +namespace MareSynchronos.MareConfiguration; + +public class NotificationsConfigService : ConfigurationServiceBase +{ + public const string ConfigName = "notifications.json"; + + public NotificationsConfigService(string configDir) : base(configDir) + { + } + + public override string ConfigurationName => ConfigName; +} \ No newline at end of file diff --git a/MareSynchronos/Plugin.cs b/MareSynchronos/Plugin.cs index 87d2e40..b198e8d 100644 --- a/MareSynchronos/Plugin.cs +++ b/MareSynchronos/Plugin.cs @@ -167,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>(s => s.GetRequiredService()); collection.AddSingleton>(s => s.GetRequiredService()); collection.AddSingleton>(s => s.GetRequiredService()); @@ -178,6 +179,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton>(s => s.GetRequiredService()); collection.AddSingleton>(s => s.GetRequiredService()); collection.AddSingleton>(s => s.GetRequiredService()); + collection.AddSingleton>(s => s.GetRequiredService()); collection.AddSingleton(); collection.AddSingleton(); diff --git a/MareSynchronos/Services/Notification/NotificationTracker.cs b/MareSynchronos/Services/Notification/NotificationTracker.cs index ce1c6a9..2d5c6aa 100644 --- a/MareSynchronos/Services/Notification/NotificationTracker.cs +++ b/MareSynchronos/Services/Notification/NotificationTracker.cs @@ -2,29 +2,45 @@ 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) + public NotificationTracker(MareMediator mediator, NotificationsConfigService configService) { _mediator = mediator; + _configService = configService; + LoadPersisted(); + PublishState(); } public void Upsert(NotificationEntry entry) @@ -32,6 +48,8 @@ public sealed class NotificationTracker lock (_lock) { _entries[(entry.Category, entry.Id)] = entry; + TrimIfNecessary_NoLock(); + Persist_NoLock(); } PublishState(); } @@ -41,6 +59,7 @@ public sealed class NotificationTracker lock (_lock) { _entries.Remove((category, id)); + Persist_NoLock(); } PublishState(); } @@ -70,4 +89,56 @@ public sealed class NotificationTracker { _mediator.Publish(new NotificationStateChanged(Count)); } + + private void LoadPersisted() + { + try + { + var list = _configService.Current.Notifications ?? new List(); + foreach (var s in list) + { + if (!Enum.TryParse(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)); + } + } } diff --git a/MareSynchronos/Services/NotificationService.cs b/MareSynchronos/Services/NotificationService.cs index a06036b..d76b52d 100644 --- a/MareSynchronos/Services/NotificationService.cs +++ b/MareSynchronos/Services/NotificationService.cs @@ -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 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(this, ShowNotification); Mediator.Subscribe(this, ShowDualNotification); + Mediator.Subscribe(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; diff --git a/MareSynchronos/Services/UiFactory.cs b/MareSynchronos/Services/UiFactory.cs index e2364b7..7a0c392 100644 --- a/MareSynchronos/Services/UiFactory.cs +++ b/MareSynchronos/Services/UiFactory.cs @@ -3,6 +3,7 @@ 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; @@ -21,10 +22,11 @@ public class UiFactory 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, SyncshellDiscoveryService syncshellDiscoveryService, ServerConfigurationManager serverConfigManager, - MareProfileManager mareProfileManager, PerformanceCollectorService performanceCollectorService) + MareProfileManager mareProfileManager, PerformanceCollectorService performanceCollectorService, NotificationTracker notificationTracker) { _loggerFactory = loggerFactory; _mareMediator = mareMediator; @@ -35,12 +37,13 @@ public class UiFactory _serverConfigManager = serverConfigManager; _mareProfileManager = mareProfileManager; _performanceCollectorService = performanceCollectorService; + _notificationTracker = notificationTracker; } public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto) { return new SyncshellAdminUI(_loggerFactory.CreateLogger(), _mareMediator, - _apiController, _uiSharedService, _pairManager, _syncshellDiscoveryService, dto, _performanceCollectorService); + _apiController, _uiSharedService, _pairManager, _syncshellDiscoveryService, dto, _performanceCollectorService, _notificationTracker); } public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair) diff --git a/MareSynchronos/UI/CompactUI.cs b/MareSynchronos/UI/CompactUI.cs index a149ef3..9abbbc0 100644 --- a/MareSynchronos/UI/CompactUI.cs +++ b/MareSynchronos/UI/CompactUI.cs @@ -860,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)) @@ -891,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); } @@ -1173,6 +1176,9 @@ if (showNearby && pendingInvites > 0) case NotificationCategory.AutoDetect: DrawAutoDetectNotification(notification); break; + case NotificationCategory.Syncshell: + DrawSyncshellNotification(notification); + break; default: UiSharedService.DrawCard($"notification-{notification.Category}-{notification.Id}", () => { @@ -1237,6 +1243,30 @@ if (showNearby && pendingInvites > 0) }, 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 () => diff --git a/MareSynchronos/UI/SyncshellAdminUI.cs b/MareSynchronos/UI/SyncshellAdminUI.cs index 0365d5a..1408b49 100644 --- a/MareSynchronos/UI/SyncshellAdminUI.cs +++ b/MareSynchronos/UI/SyncshellAdminUI.cs @@ -11,11 +11,13 @@ 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; @@ -28,6 +30,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase private readonly PairManager _pairManager; private readonly UiSharedService _uiSharedService; private readonly SyncshellDiscoveryService _syncshellDiscoveryService; + private readonly NotificationTracker _notificationTracker; private List _bannedUsers = []; private int _multiInvites; private string _newPassword; @@ -54,7 +57,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase public SyncshellAdminUI(ILogger logger, MareMediator mediator, ApiController apiController, UiSharedService uiSharedService, PairManager pairManager, SyncshellDiscoveryService syncshellDiscoveryService, - GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService) + GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, NotificationTracker notificationTracker) : base(logger, mediator, "Syncshell Admin Panel (" + groupFullInfo.GroupAliasOrGID + ")", performanceCollectorService) { GroupFullInfo = groupFullInfo; @@ -62,6 +65,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase _uiSharedService = uiSharedService; _pairManager = pairManager; _syncshellDiscoveryService = syncshellDiscoveryService; + _notificationTracker = notificationTracker; _isOwner = string.Equals(GroupFullInfo.OwnerUID, _apiController.UID, StringComparison.Ordinal); _isModerator = GroupFullInfo.GroupUserInfo.IsModerator(); _newPassword = string.Empty; @@ -650,6 +654,11 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase _autoDetectMessage = desiredVisibility ? "La Syncshell est désormais visible dans AutoDetect." : "La Syncshell n'est plus visible dans AutoDetect."; + + if (desiredVisibility) + { + PublishSyncshellPublicNotification(); + } } catch (Exception ex) { @@ -711,6 +720,11 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase _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) { @@ -744,4 +758,19 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase { 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 + } + } } diff --git a/MareSynchronos/WebAPI/SignalR/HubFactory.cs b/MareSynchronos/WebAPI/SignalR/HubFactory.cs index 887cca2..cbe3d14 100644 --- a/MareSynchronos/WebAPI/SignalR/HubFactory.cs +++ b/MareSynchronos/WebAPI/SignalR/HubFactory.cs @@ -174,9 +174,10 @@ public class HubFactory : MediatorSubscriberBase }) .AddMessagePackProtocol(opt => { - var resolver = CompositeResolver.Create(StandardResolverAllowPrivate.Instance, - BuiltinResolver.Instance, + var resolver = CompositeResolver.Create( AttributeFormatterResolver.Instance, + StandardResolverAllowPrivate.Instance, + BuiltinResolver.Instance, // replace enum resolver DynamicEnumAsStringResolver.Instance, DynamicGenericResolver.Instance,