Merge pull request 'pair-notifs-ui' (#57) from pair-notifs-ui into 1.12.2
Reviewed-on: #57 Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
This commit was merged in pull request #57.
This commit is contained in:
@@ -63,6 +63,7 @@ public class LightlessConfig : ILightlessConfiguration
|
|||||||
public bool ShowOnlineNotificationsOnlyForNamedPairs { get; set; } = false;
|
public bool ShowOnlineNotificationsOnlyForNamedPairs { get; set; } = false;
|
||||||
public bool ShowTransferBars { get; set; } = true;
|
public bool ShowTransferBars { get; set; } = true;
|
||||||
public bool ShowTransferWindow { get; set; } = false;
|
public bool ShowTransferWindow { get; set; } = false;
|
||||||
|
public bool UseNotificationsForDownloads { get; set; } = true;
|
||||||
public bool ShowUploading { get; set; } = true;
|
public bool ShowUploading { get; set; } = true;
|
||||||
public bool ShowUploadingBigText { get; set; } = true;
|
public bool ShowUploadingBigText { get; set; } = true;
|
||||||
public bool ShowVisibleUsersSeparately { get; set; } = true;
|
public bool ShowVisibleUsersSeparately { get; set; } = true;
|
||||||
@@ -76,6 +77,49 @@ public class LightlessConfig : ILightlessConfiguration
|
|||||||
public bool AutoPopulateEmptyNotesFromCharaName { get; set; } = false;
|
public bool AutoPopulateEmptyNotesFromCharaName { get; set; } = false;
|
||||||
public int Version { get; set; } = 1;
|
public int Version { get; set; } = 1;
|
||||||
public NotificationLocation WarningNotification { get; set; } = NotificationLocation.Both;
|
public NotificationLocation WarningNotification { get; set; } = NotificationLocation.Both;
|
||||||
|
|
||||||
|
// Lightless Notification Configuration
|
||||||
|
public bool UseLightlessNotifications { get; set; } = true;
|
||||||
|
public bool ShowNotificationProgress { get; set; } = true;
|
||||||
|
public NotificationLocation LightlessInfoNotification { get; set; } = NotificationLocation.LightlessUi;
|
||||||
|
public NotificationLocation LightlessWarningNotification { get; set; } = NotificationLocation.LightlessUi;
|
||||||
|
public NotificationLocation LightlessErrorNotification { get; set; } = NotificationLocation.ChatAndLightlessUi;
|
||||||
|
public NotificationLocation LightlessPairRequestNotification { get; set; } = NotificationLocation.LightlessUi;
|
||||||
|
public NotificationLocation LightlessDownloadNotification { get; set; } = NotificationLocation.TextOverlay;
|
||||||
|
|
||||||
|
// Basic Settings
|
||||||
|
public float NotificationOpacity { get; set; } = 0.95f;
|
||||||
|
public int MaxSimultaneousNotifications { get; set; } = 5;
|
||||||
|
public bool AutoDismissOnAction { get; set; } = true;
|
||||||
|
public bool DismissNotificationOnClick { get; set; } = false;
|
||||||
|
public bool ShowNotificationTimestamp { get; set; } = false;
|
||||||
|
|
||||||
|
// Position & Layout
|
||||||
|
public int NotificationOffsetY { get; set; } = 50;
|
||||||
|
public int NotificationOffsetX { get; set; } = 0;
|
||||||
|
public float NotificationWidth { get; set; } = 350f;
|
||||||
|
public float NotificationSpacing { get; set; } = 8f;
|
||||||
|
|
||||||
|
// Animation & Effects
|
||||||
|
public float NotificationAnimationSpeed { get; set; } = 10f;
|
||||||
|
public float NotificationAccentBarWidth { get; set; } = 3f;
|
||||||
|
|
||||||
|
// Duration per Type
|
||||||
|
public int InfoNotificationDurationSeconds { get; set; } = 10;
|
||||||
|
public int WarningNotificationDurationSeconds { get; set; } = 15;
|
||||||
|
public int ErrorNotificationDurationSeconds { get; set; } = 20;
|
||||||
|
public int PairRequestDurationSeconds { get; set; } = 180;
|
||||||
|
public int DownloadNotificationDurationSeconds { get; set; } = 300;
|
||||||
|
public uint CustomInfoSoundId { get; set; } = 2; // Se2
|
||||||
|
public uint CustomWarningSoundId { get; set; } = 16; // Se15
|
||||||
|
public uint CustomErrorSoundId { get; set; } = 16; // Se15
|
||||||
|
public uint PairRequestSoundId { get; set; } = 5; // Se5
|
||||||
|
public uint DownloadSoundId { get; set; } = 15; // Se14
|
||||||
|
public bool DisableInfoSound { get; set; } = false;
|
||||||
|
public bool DisableWarningSound { get; set; } = false;
|
||||||
|
public bool DisableErrorSound { get; set; } = false;
|
||||||
|
public bool DisablePairRequestSound { get; set; } = false;
|
||||||
|
public bool DisableDownloadSound { get; set; } = true; // Disabled by default (annoying)
|
||||||
public bool UseFocusTarget { get; set; } = false;
|
public bool UseFocusTarget { get; set; } = false;
|
||||||
public bool overrideFriendColor { get; set; } = false;
|
public bool overrideFriendColor { get; set; } = false;
|
||||||
public bool overridePartyColor { get; set; } = false;
|
public bool overridePartyColor { get; set; } = false;
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
namespace LightlessSync.LightlessConfiguration.Models;
|
namespace LightlessSync.LightlessConfiguration.Models;
|
||||||
|
|
||||||
public enum NotificationLocation
|
public enum NotificationLocation
|
||||||
{
|
{
|
||||||
Nowhere,
|
Nowhere,
|
||||||
Chat,
|
Chat,
|
||||||
Toast,
|
Toast,
|
||||||
Both
|
Both,
|
||||||
|
LightlessUi,
|
||||||
|
ChatAndLightlessUi,
|
||||||
|
TextOverlay,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum NotificationType
|
public enum NotificationType
|
||||||
{
|
{
|
||||||
Info,
|
Info,
|
||||||
Warning,
|
Warning,
|
||||||
Error
|
Error,
|
||||||
|
PairRequest,
|
||||||
|
Download
|
||||||
}
|
}
|
||||||
@@ -182,9 +182,13 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<IpcCallerPenumbra>(), s.GetRequiredService<IpcCallerGlamourer>(),
|
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<IpcCallerPenumbra>(), s.GetRequiredService<IpcCallerGlamourer>(),
|
||||||
s.GetRequiredService<IpcCallerCustomize>(), s.GetRequiredService<IpcCallerHeels>(), s.GetRequiredService<IpcCallerHonorific>(),
|
s.GetRequiredService<IpcCallerCustomize>(), s.GetRequiredService<IpcCallerHeels>(), s.GetRequiredService<IpcCallerHonorific>(),
|
||||||
s.GetRequiredService<IpcCallerMoodles>(), s.GetRequiredService<IpcCallerPetNames>(), s.GetRequiredService<IpcCallerBrio>()));
|
s.GetRequiredService<IpcCallerMoodles>(), s.GetRequiredService<IpcCallerPetNames>(), s.GetRequiredService<IpcCallerBrio>()));
|
||||||
collection.AddSingleton((s) => new NotificationService(s.GetRequiredService<ILogger<NotificationService>>(),
|
collection.AddSingleton((s) => new NotificationService(
|
||||||
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<DalamudUtilService>(),
|
s.GetRequiredService<ILogger<NotificationService>>(),
|
||||||
notificationManager, chatGui, s.GetRequiredService<LightlessConfigService>()));
|
s.GetRequiredService<LightlessConfigService>(),
|
||||||
|
s.GetRequiredService<DalamudUtilService>(),
|
||||||
|
notificationManager,
|
||||||
|
chatGui,
|
||||||
|
s.GetRequiredService<LightlessMediator>()));
|
||||||
collection.AddSingleton((s) =>
|
collection.AddSingleton((s) =>
|
||||||
{
|
{
|
||||||
var httpClient = new HttpClient();
|
var httpClient = new HttpClient();
|
||||||
@@ -248,6 +252,12 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
collection.AddScoped<WindowMediatorSubscriberBase, BroadcastUI>((s) => new BroadcastUI(s.GetRequiredService<ILogger<BroadcastUI>>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(), s.GetRequiredService<BroadcastService>(), s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<BroadcastScannerService>()));
|
collection.AddScoped<WindowMediatorSubscriberBase, BroadcastUI>((s) => new BroadcastUI(s.GetRequiredService<ILogger<BroadcastUI>>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(), s.GetRequiredService<BroadcastService>(), s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<BroadcastScannerService>()));
|
||||||
collection.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>((s) => new SyncshellFinderUI(s.GetRequiredService<ILogger<SyncshellFinderUI>>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(), s.GetRequiredService<BroadcastService>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<BroadcastScannerService>(), s.GetRequiredService<PairManager>(), s.GetRequiredService<DalamudUtilService>()));
|
collection.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>((s) => new SyncshellFinderUI(s.GetRequiredService<ILogger<SyncshellFinderUI>>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(), s.GetRequiredService<BroadcastService>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<BroadcastScannerService>(), s.GetRequiredService<PairManager>(), s.GetRequiredService<DalamudUtilService>()));
|
||||||
collection.AddScoped<IPopupHandler, BanUserPopupHandler>();
|
collection.AddScoped<IPopupHandler, BanUserPopupHandler>();
|
||||||
|
collection.AddScoped<WindowMediatorSubscriberBase, LightlessNotificationUI>((s) =>
|
||||||
|
new LightlessNotificationUI(
|
||||||
|
s.GetRequiredService<ILogger<LightlessNotificationUI>>(),
|
||||||
|
s.GetRequiredService<LightlessMediator>(),
|
||||||
|
s.GetRequiredService<PerformanceCollectorService>(),
|
||||||
|
s.GetRequiredService<LightlessConfigService>()));
|
||||||
collection.AddScoped<IPopupHandler, CensusPopupHandler>();
|
collection.AddScoped<IPopupHandler, CensusPopupHandler>();
|
||||||
collection.AddScoped<CacheCreationService>();
|
collection.AddScoped<CacheCreationService>();
|
||||||
collection.AddScoped<PlayerDataFactory>();
|
collection.AddScoped<PlayerDataFactory>();
|
||||||
@@ -255,7 +265,9 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
collection.AddScoped((s) => new UiService(s.GetRequiredService<ILogger<UiService>>(), pluginInterface.UiBuilder, s.GetRequiredService<LightlessConfigService>(),
|
collection.AddScoped((s) => new UiService(s.GetRequiredService<ILogger<UiService>>(), pluginInterface.UiBuilder, s.GetRequiredService<LightlessConfigService>(),
|
||||||
s.GetRequiredService<WindowSystem>(), s.GetServices<WindowMediatorSubscriberBase>(),
|
s.GetRequiredService<WindowSystem>(), s.GetServices<WindowMediatorSubscriberBase>(),
|
||||||
s.GetRequiredService<UiFactory>(),
|
s.GetRequiredService<UiFactory>(),
|
||||||
s.GetRequiredService<FileDialogManager>(), s.GetRequiredService<LightlessMediator>()));
|
s.GetRequiredService<FileDialogManager>(),
|
||||||
|
s.GetRequiredService<LightlessMediator>(),
|
||||||
|
s.GetRequiredService<NotificationService>()));
|
||||||
collection.AddScoped((s) => new CommandManagerService(commandManager, s.GetRequiredService<PerformanceCollectorService>(),
|
collection.AddScoped((s) => new CommandManagerService(commandManager, s.GetRequiredService<PerformanceCollectorService>(),
|
||||||
s.GetRequiredService<ServerConfigurationManager>(), s.GetRequiredService<CacheMonitor>(), s.GetRequiredService<ApiController>(),
|
s.GetRequiredService<ServerConfigurationManager>(), s.GetRequiredService<CacheMonitor>(), s.GetRequiredService<ApiController>(),
|
||||||
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<LightlessConfigService>()));
|
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<LightlessConfigService>()));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Dalamud.Game.ClientState.Objects.Types;
|
using Dalamud.Game.ClientState.Objects.Types;
|
||||||
using LightlessSync.API.Data;
|
using LightlessSync.API.Data;
|
||||||
using LightlessSync.API.Dto;
|
using LightlessSync.API.Dto;
|
||||||
using LightlessSync.API.Dto.CharaData;
|
using LightlessSync.API.Dto.CharaData;
|
||||||
@@ -54,6 +54,8 @@ public record NotificationMessage
|
|||||||
public record CreateCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : SameThreadMessage;
|
public record CreateCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : SameThreadMessage;
|
||||||
public record ClearCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : SameThreadMessage;
|
public record ClearCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : SameThreadMessage;
|
||||||
public record CharacterDataCreatedMessage(CharacterData CharacterData) : SameThreadMessage;
|
public record CharacterDataCreatedMessage(CharacterData CharacterData) : SameThreadMessage;
|
||||||
|
public record LightlessNotificationMessage(LightlessSync.UI.Models.LightlessNotification Notification) : MessageBase;
|
||||||
|
public record LightlessNotificationDismissMessage(string NotificationId) : MessageBase;
|
||||||
public record CharacterDataAnalyzedMessage : MessageBase;
|
public record CharacterDataAnalyzedMessage : MessageBase;
|
||||||
public record PenumbraStartRedrawMessage(IntPtr Address) : MessageBase;
|
public record PenumbraStartRedrawMessage(IntPtr Address) : MessageBase;
|
||||||
public record PenumbraEndRedrawMessage(IntPtr Address) : MessageBase;
|
public record PenumbraEndRedrawMessage(IntPtr Address) : MessageBase;
|
||||||
|
|||||||
@@ -1,42 +1,492 @@
|
|||||||
using Dalamud.Game.Text.SeStringHandling;
|
using Dalamud.Game.Text.SeStringHandling;
|
||||||
|
using Dalamud.Interface;
|
||||||
using Dalamud.Interface.ImGuiNotification;
|
using Dalamud.Interface.ImGuiNotification;
|
||||||
using Dalamud.Plugin.Services;
|
using Dalamud.Plugin.Services;
|
||||||
using LightlessSync.LightlessConfiguration;
|
using LightlessSync.LightlessConfiguration;
|
||||||
using LightlessSync.LightlessConfiguration.Models;
|
using LightlessSync.LightlessConfiguration.Models;
|
||||||
using LightlessSync.Services.Mediator;
|
using LightlessSync.Services.Mediator;
|
||||||
|
using LightlessSync.UI;
|
||||||
|
using LightlessSync.UI.Models;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||||
using NotificationType = LightlessSync.LightlessConfiguration.Models.NotificationType;
|
using NotificationType = LightlessSync.LightlessConfiguration.Models.NotificationType;
|
||||||
|
|
||||||
namespace LightlessSync.Services;
|
namespace LightlessSync.Services;
|
||||||
|
|
||||||
public class NotificationService : DisposableMediatorSubscriberBase, IHostedService
|
public class NotificationService : DisposableMediatorSubscriberBase, IHostedService
|
||||||
{
|
{
|
||||||
|
private readonly ILogger<NotificationService> _logger;
|
||||||
|
private readonly LightlessConfigService _configService;
|
||||||
private readonly DalamudUtilService _dalamudUtilService;
|
private readonly DalamudUtilService _dalamudUtilService;
|
||||||
private readonly INotificationManager _notificationManager;
|
private readonly INotificationManager _notificationManager;
|
||||||
private readonly IChatGui _chatGui;
|
private readonly IChatGui _chatGui;
|
||||||
private readonly LightlessConfigService _configurationService;
|
public NotificationService(
|
||||||
|
ILogger<NotificationService> logger,
|
||||||
public NotificationService(ILogger<NotificationService> logger, LightlessMediator mediator,
|
LightlessConfigService configService,
|
||||||
DalamudUtilService dalamudUtilService,
|
DalamudUtilService dalamudUtilService,
|
||||||
INotificationManager notificationManager,
|
INotificationManager notificationManager,
|
||||||
IChatGui chatGui, LightlessConfigService configurationService) : base(logger, mediator)
|
IChatGui chatGui,
|
||||||
|
LightlessMediator mediator) : base(logger, mediator)
|
||||||
{
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_configService = configService;
|
||||||
_dalamudUtilService = dalamudUtilService;
|
_dalamudUtilService = dalamudUtilService;
|
||||||
_notificationManager = notificationManager;
|
_notificationManager = notificationManager;
|
||||||
_chatGui = chatGui;
|
_chatGui = chatGui;
|
||||||
_configurationService = configurationService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task StartAsync(CancellationToken cancellationToken)
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Mediator.Subscribe<NotificationMessage>(this, ShowNotification);
|
Mediator.Subscribe<NotificationMessage>(this, HandleNotificationMessage);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
public Task StopAsync(CancellationToken cancellationToken)
|
public void ShowNotification(string title, string message, NotificationType type = NotificationType.Info,
|
||||||
|
TimeSpan? duration = null, List<LightlessNotificationAction>? actions = null, uint? soundEffectId = null)
|
||||||
{
|
{
|
||||||
return Task.CompletedTask;
|
var notification = CreateNotification(title, message, type, duration, actions, soundEffectId);
|
||||||
|
|
||||||
|
if (_configService.Current.AutoDismissOnAction && notification.Actions.Any())
|
||||||
|
{
|
||||||
|
WrapActionsWithAutoDismiss(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.SoundEffectId.HasValue)
|
||||||
|
{
|
||||||
|
PlayNotificationSound(notification.SoundEffectId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Mediator.Publish(new LightlessNotificationMessage(notification));
|
||||||
|
}
|
||||||
|
|
||||||
|
private LightlessNotification CreateNotification(string title, string message, NotificationType type,
|
||||||
|
TimeSpan? duration, List<LightlessNotificationAction>? actions, uint? soundEffectId)
|
||||||
|
{
|
||||||
|
return new LightlessNotification
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
Message = message,
|
||||||
|
Type = type,
|
||||||
|
Duration = duration ?? GetDefaultDurationForType(type),
|
||||||
|
Actions = actions ?? new List<LightlessNotificationAction>(),
|
||||||
|
SoundEffectId = GetSoundEffectId(type, soundEffectId),
|
||||||
|
ShowProgress = _configService.Current.ShowNotificationProgress,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WrapActionsWithAutoDismiss(LightlessNotification notification)
|
||||||
|
{
|
||||||
|
foreach (var action in notification.Actions)
|
||||||
|
{
|
||||||
|
var originalOnClick = action.OnClick;
|
||||||
|
action.OnClick = (n) =>
|
||||||
|
{
|
||||||
|
originalOnClick(n);
|
||||||
|
if (_configService.Current.AutoDismissOnAction)
|
||||||
|
{
|
||||||
|
DismissNotification(n);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DismissNotification(LightlessNotification notification)
|
||||||
|
{
|
||||||
|
notification.IsDismissed = true;
|
||||||
|
notification.IsAnimatingOut = true;
|
||||||
|
}
|
||||||
|
public void ShowPairRequestNotification(string senderName, string senderId, Action onAccept, Action onDecline)
|
||||||
|
{
|
||||||
|
var notification = new LightlessNotification
|
||||||
|
{
|
||||||
|
Title = "Pair Request Received",
|
||||||
|
Message = $"{senderName} wants to directly pair with you.",
|
||||||
|
Type = NotificationType.PairRequest,
|
||||||
|
Duration = TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds),
|
||||||
|
SoundEffectId = GetPairRequestSoundId(),
|
||||||
|
Actions = CreatePairRequestActions(onAccept, onDecline)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (notification.SoundEffectId.HasValue)
|
||||||
|
{
|
||||||
|
PlayNotificationSound(notification.SoundEffectId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Mediator.Publish(new LightlessNotificationMessage(notification));
|
||||||
|
}
|
||||||
|
|
||||||
|
private uint? GetPairRequestSoundId() =>
|
||||||
|
!_configService.Current.DisablePairRequestSound ? _configService.Current.PairRequestSoundId : null;
|
||||||
|
|
||||||
|
private List<LightlessNotificationAction> CreatePairRequestActions(Action onAccept, Action onDecline)
|
||||||
|
{
|
||||||
|
return new List<LightlessNotificationAction>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = "accept",
|
||||||
|
Label = "Accept",
|
||||||
|
Icon = FontAwesomeIcon.Check,
|
||||||
|
Color = UIColors.Get("LightlessGreen"),
|
||||||
|
IsPrimary = true,
|
||||||
|
OnClick = (n) =>
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Pair request accepted");
|
||||||
|
onAccept();
|
||||||
|
DismissNotification(n);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = "decline",
|
||||||
|
Label = "Decline",
|
||||||
|
Icon = FontAwesomeIcon.Times,
|
||||||
|
Color = UIColors.Get("DimRed"),
|
||||||
|
IsDestructive = true,
|
||||||
|
OnClick = (n) =>
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Pair request declined");
|
||||||
|
onDecline();
|
||||||
|
DismissNotification(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
public void ShowDownloadCompleteNotification(string fileName, int fileCount, Action? onOpenFolder = null)
|
||||||
|
{
|
||||||
|
var notification = new LightlessNotification
|
||||||
|
{
|
||||||
|
Title = "Download Complete",
|
||||||
|
Message = FormatDownloadCompleteMessage(fileName, fileCount),
|
||||||
|
Type = NotificationType.Info,
|
||||||
|
Duration = TimeSpan.FromSeconds(8),
|
||||||
|
Actions = CreateDownloadCompleteActions(onOpenFolder),
|
||||||
|
SoundEffectId = NotificationSounds.DownloadComplete
|
||||||
|
};
|
||||||
|
|
||||||
|
if (notification.SoundEffectId.HasValue)
|
||||||
|
{
|
||||||
|
PlayNotificationSound(notification.SoundEffectId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Mediator.Publish(new LightlessNotificationMessage(notification));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatDownloadCompleteMessage(string fileName, int fileCount) =>
|
||||||
|
fileCount > 1
|
||||||
|
? $"Downloaded {fileCount} files successfully."
|
||||||
|
: $"Downloaded {fileName} successfully.";
|
||||||
|
|
||||||
|
private List<LightlessNotificationAction> CreateDownloadCompleteActions(Action? onOpenFolder)
|
||||||
|
{
|
||||||
|
var actions = new List<LightlessNotificationAction>();
|
||||||
|
|
||||||
|
if (onOpenFolder != null)
|
||||||
|
{
|
||||||
|
actions.Add(new LightlessNotificationAction
|
||||||
|
{
|
||||||
|
Id = "open_folder",
|
||||||
|
Label = "Open Folder",
|
||||||
|
Icon = FontAwesomeIcon.FolderOpen,
|
||||||
|
Color = UIColors.Get("LightlessBlue"),
|
||||||
|
OnClick = (n) =>
|
||||||
|
{
|
||||||
|
onOpenFolder();
|
||||||
|
DismissNotification(n);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
public void ShowErrorNotification(string title, string message, Exception? exception = null, Action? onRetry = null, Action? onViewLog = null)
|
||||||
|
{
|
||||||
|
var notification = new LightlessNotification
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
Message = FormatErrorMessage(message, exception),
|
||||||
|
Type = NotificationType.Error,
|
||||||
|
Duration = TimeSpan.FromSeconds(15),
|
||||||
|
Actions = CreateErrorActions(onRetry, onViewLog),
|
||||||
|
SoundEffectId = NotificationSounds.Error
|
||||||
|
};
|
||||||
|
|
||||||
|
if (notification.SoundEffectId.HasValue)
|
||||||
|
{
|
||||||
|
PlayNotificationSound(notification.SoundEffectId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Mediator.Publish(new LightlessNotificationMessage(notification));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatErrorMessage(string message, Exception? exception) =>
|
||||||
|
exception != null ? $"{message}\n\nError: {exception.Message}" : message;
|
||||||
|
|
||||||
|
private List<LightlessNotificationAction> CreateErrorActions(Action? onRetry, Action? onViewLog)
|
||||||
|
{
|
||||||
|
var actions = new List<LightlessNotificationAction>();
|
||||||
|
|
||||||
|
if (onRetry != null)
|
||||||
|
{
|
||||||
|
actions.Add(new LightlessNotificationAction
|
||||||
|
{
|
||||||
|
Id = "retry",
|
||||||
|
Label = "Retry",
|
||||||
|
Icon = FontAwesomeIcon.Redo,
|
||||||
|
Color = UIColors.Get("LightlessBlue"),
|
||||||
|
OnClick = (n) =>
|
||||||
|
{
|
||||||
|
onRetry();
|
||||||
|
DismissNotification(n);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onViewLog != null)
|
||||||
|
{
|
||||||
|
actions.Add(new LightlessNotificationAction
|
||||||
|
{
|
||||||
|
Id = "view_log",
|
||||||
|
Label = "View Log",
|
||||||
|
Icon = FontAwesomeIcon.FileAlt,
|
||||||
|
Color = UIColors.Get("LightlessYellow"),
|
||||||
|
OnClick = (n) => onViewLog()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
public void ShowPairDownloadNotification(List<(string playerName, float progress, string status)> downloadStatus, int queueWaiting = 0)
|
||||||
|
{
|
||||||
|
var userDownloads = downloadStatus.Where(x => x.playerName != "Pair Queue").ToList();
|
||||||
|
var totalProgress = userDownloads.Count > 0 ? userDownloads.Average(x => x.progress) : 0f;
|
||||||
|
var message = BuildPairDownloadMessage(userDownloads, queueWaiting);
|
||||||
|
|
||||||
|
var notification = new LightlessNotification
|
||||||
|
{
|
||||||
|
Id = "pair_download_progress",
|
||||||
|
Title = "Downloading Pair Data",
|
||||||
|
Message = message,
|
||||||
|
Type = NotificationType.Download,
|
||||||
|
Duration = TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds),
|
||||||
|
ShowProgress = true,
|
||||||
|
Progress = totalProgress
|
||||||
|
};
|
||||||
|
|
||||||
|
Mediator.Publish(new LightlessNotificationMessage(notification));
|
||||||
|
|
||||||
|
if (AreAllDownloadsCompleted(userDownloads))
|
||||||
|
{
|
||||||
|
DismissPairDownloadNotification();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildPairDownloadMessage(List<(string playerName, float progress, string status)> userDownloads, int queueWaiting)
|
||||||
|
{
|
||||||
|
var messageParts = new List<string>();
|
||||||
|
|
||||||
|
if (queueWaiting > 0)
|
||||||
|
{
|
||||||
|
messageParts.Add($"Queue: {queueWaiting} waiting");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userDownloads.Count > 0)
|
||||||
|
{
|
||||||
|
var completedCount = userDownloads.Count(x => x.progress >= 1.0f);
|
||||||
|
messageParts.Add($"Progress: {completedCount}/{userDownloads.Count} completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeDownloadLines = BuildActiveDownloadLines(userDownloads);
|
||||||
|
if (!string.IsNullOrEmpty(activeDownloadLines))
|
||||||
|
{
|
||||||
|
messageParts.Add(activeDownloadLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join("\n", messageParts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildActiveDownloadLines(List<(string playerName, float progress, string status)> userDownloads)
|
||||||
|
{
|
||||||
|
var activeDownloads = userDownloads
|
||||||
|
.Where(x => x.progress < 1.0f)
|
||||||
|
.Take(_configService.Current.MaxConcurrentPairApplications);
|
||||||
|
|
||||||
|
if (!activeDownloads.Any()) return string.Empty;
|
||||||
|
|
||||||
|
return string.Join("\n", activeDownloads.Select(x => $"• {x.playerName}: {FormatDownloadStatus(x)}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatDownloadStatus((string playerName, float progress, string status) download) => download.status switch
|
||||||
|
{
|
||||||
|
"downloading" => $"{download.progress:P0}",
|
||||||
|
"decompressing" => "decompressing",
|
||||||
|
"queued" => "queued",
|
||||||
|
"waiting" => "waiting for slot",
|
||||||
|
_ => download.status
|
||||||
|
};
|
||||||
|
|
||||||
|
private bool AreAllDownloadsCompleted(List<(string playerName, float progress, string status)> userDownloads) =>
|
||||||
|
userDownloads.Any() && userDownloads.All(x => x.progress >= 1.0f);
|
||||||
|
|
||||||
|
public void DismissPairDownloadNotification() =>
|
||||||
|
Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress"));
|
||||||
|
|
||||||
|
private TimeSpan GetDefaultDurationForType(NotificationType type) => type switch
|
||||||
|
{
|
||||||
|
NotificationType.Info => TimeSpan.FromSeconds(_configService.Current.InfoNotificationDurationSeconds),
|
||||||
|
NotificationType.Warning => TimeSpan.FromSeconds(_configService.Current.WarningNotificationDurationSeconds),
|
||||||
|
NotificationType.Error => TimeSpan.FromSeconds(_configService.Current.ErrorNotificationDurationSeconds),
|
||||||
|
NotificationType.PairRequest => TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds),
|
||||||
|
NotificationType.Download => TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds),
|
||||||
|
_ => TimeSpan.FromSeconds(10)
|
||||||
|
};
|
||||||
|
|
||||||
|
private uint? GetSoundEffectId(NotificationType type, uint? overrideSoundId)
|
||||||
|
{
|
||||||
|
if (overrideSoundId.HasValue) return overrideSoundId;
|
||||||
|
if (IsSoundDisabledForType(type)) return null;
|
||||||
|
return GetConfiguredSoundForType(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsSoundDisabledForType(NotificationType type) => type switch
|
||||||
|
{
|
||||||
|
NotificationType.Info => _configService.Current.DisableInfoSound,
|
||||||
|
NotificationType.Warning => _configService.Current.DisableWarningSound,
|
||||||
|
NotificationType.Error => _configService.Current.DisableErrorSound,
|
||||||
|
NotificationType.Download => _configService.Current.DisableDownloadSound,
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
|
||||||
|
private uint GetConfiguredSoundForType(NotificationType type) => type switch
|
||||||
|
{
|
||||||
|
NotificationType.Info => _configService.Current.CustomInfoSoundId,
|
||||||
|
NotificationType.Warning => _configService.Current.CustomWarningSoundId,
|
||||||
|
NotificationType.Error => _configService.Current.CustomErrorSoundId,
|
||||||
|
NotificationType.Download => _configService.Current.DownloadSoundId,
|
||||||
|
_ => NotificationSounds.GetDefaultSound(type)
|
||||||
|
};
|
||||||
|
|
||||||
|
private void PlayNotificationSound(uint soundEffectId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
UIGlobals.PlayChatSoundEffect(soundEffectId);
|
||||||
|
_logger.LogDebug("Played notification sound effect {SoundId} via ChatGui", soundEffectId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to play notification sound effect {SoundId}", soundEffectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleNotificationMessage(NotificationMessage msg)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("{msg}", msg.ToString());
|
||||||
|
if (!_dalamudUtilService.IsLoggedIn) return;
|
||||||
|
|
||||||
|
var location = GetNotificationLocation(msg.Type);
|
||||||
|
ShowNotificationLocationBased(msg, location);
|
||||||
|
}
|
||||||
|
|
||||||
|
private NotificationLocation GetNotificationLocation(NotificationType type) =>
|
||||||
|
_configService.Current.UseLightlessNotifications
|
||||||
|
? GetLightlessNotificationLocation(type)
|
||||||
|
: GetClassicNotificationLocation(type);
|
||||||
|
|
||||||
|
private NotificationLocation GetLightlessNotificationLocation(NotificationType type) => type switch
|
||||||
|
{
|
||||||
|
NotificationType.Info => _configService.Current.LightlessInfoNotification,
|
||||||
|
NotificationType.Warning => _configService.Current.LightlessWarningNotification,
|
||||||
|
NotificationType.Error => _configService.Current.LightlessErrorNotification,
|
||||||
|
NotificationType.PairRequest => _configService.Current.LightlessPairRequestNotification,
|
||||||
|
NotificationType.Download => _configService.Current.LightlessDownloadNotification,
|
||||||
|
_ => NotificationLocation.LightlessUi
|
||||||
|
};
|
||||||
|
|
||||||
|
private NotificationLocation GetClassicNotificationLocation(NotificationType type) => type switch
|
||||||
|
{
|
||||||
|
NotificationType.Info => _configService.Current.InfoNotification,
|
||||||
|
NotificationType.Warning => _configService.Current.WarningNotification,
|
||||||
|
NotificationType.Error => _configService.Current.ErrorNotification,
|
||||||
|
NotificationType.PairRequest => NotificationLocation.Toast,
|
||||||
|
NotificationType.Download => NotificationLocation.Toast,
|
||||||
|
_ => NotificationLocation.Nowhere
|
||||||
|
};
|
||||||
|
|
||||||
|
private void ShowNotificationLocationBased(NotificationMessage msg, NotificationLocation location)
|
||||||
|
{
|
||||||
|
switch (location)
|
||||||
|
{
|
||||||
|
case NotificationLocation.Toast:
|
||||||
|
ShowToast(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case NotificationLocation.Chat:
|
||||||
|
ShowChat(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case NotificationLocation.Both:
|
||||||
|
ShowToast(msg);
|
||||||
|
ShowChat(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case NotificationLocation.LightlessUi:
|
||||||
|
ShowLightlessNotification(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case NotificationLocation.ChatAndLightlessUi:
|
||||||
|
ShowChat(msg);
|
||||||
|
ShowLightlessNotification(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case NotificationLocation.Nowhere:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowLightlessNotification(NotificationMessage msg)
|
||||||
|
{
|
||||||
|
var duration = msg.TimeShownOnScreen ?? GetDefaultDurationForType(msg.Type);
|
||||||
|
ShowNotification(msg.Title ?? "Lightless Sync", msg.Message ?? string.Empty, msg.Type, duration, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowToast(NotificationMessage msg)
|
||||||
|
{
|
||||||
|
var dalamudType = ConvertToDalamudNotificationType(msg.Type);
|
||||||
|
|
||||||
|
_notificationManager.AddNotification(new Notification()
|
||||||
|
{
|
||||||
|
Content = msg.Message ?? string.Empty,
|
||||||
|
Title = msg.Title,
|
||||||
|
Type = dalamudType,
|
||||||
|
Minimized = false,
|
||||||
|
InitialDuration = msg.TimeShownOnScreen ?? TimeSpan.FromSeconds(3)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dalamud.Interface.ImGuiNotification.NotificationType ConvertToDalamudNotificationType(NotificationType type) => type switch
|
||||||
|
{
|
||||||
|
NotificationType.Error => Dalamud.Interface.ImGuiNotification.NotificationType.Error,
|
||||||
|
NotificationType.Warning => Dalamud.Interface.ImGuiNotification.NotificationType.Warning,
|
||||||
|
_ => Dalamud.Interface.ImGuiNotification.NotificationType.Info
|
||||||
|
};
|
||||||
|
|
||||||
|
private void ShowChat(NotificationMessage msg)
|
||||||
|
{
|
||||||
|
switch (msg.Type)
|
||||||
|
{
|
||||||
|
case NotificationType.Info:
|
||||||
|
PrintInfoChat(msg.Message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case NotificationType.Warning:
|
||||||
|
PrintWarnChat(msg.Message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case NotificationType.Error:
|
||||||
|
PrintErrorChat(msg.Message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PrintErrorChat(string? message)
|
private void PrintErrorChat(string? message)
|
||||||
@@ -57,85 +507,4 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
|
|||||||
_chatGui.Print(se.BuiltString);
|
_chatGui.Print(se.BuiltString);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ShowChat(NotificationMessage msg)
|
|
||||||
{
|
|
||||||
switch (msg.Type)
|
|
||||||
{
|
|
||||||
case NotificationType.Info:
|
|
||||||
PrintInfoChat(msg.Message);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case NotificationType.Warning:
|
|
||||||
PrintWarnChat(msg.Message);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case NotificationType.Error:
|
|
||||||
PrintErrorChat(msg.Message);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ShowNotification(NotificationMessage msg)
|
|
||||||
{
|
|
||||||
Logger.LogInformation("{msg}", msg.ToString());
|
|
||||||
|
|
||||||
if (!_dalamudUtilService.IsLoggedIn) return;
|
|
||||||
|
|
||||||
switch (msg.Type)
|
|
||||||
{
|
|
||||||
case NotificationType.Info:
|
|
||||||
ShowNotificationLocationBased(msg, _configurationService.Current.InfoNotification);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case NotificationType.Warning:
|
|
||||||
ShowNotificationLocationBased(msg, _configurationService.Current.WarningNotification);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case NotificationType.Error:
|
|
||||||
ShowNotificationLocationBased(msg, _configurationService.Current.ErrorNotification);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ShowNotificationLocationBased(NotificationMessage msg, NotificationLocation location)
|
|
||||||
{
|
|
||||||
switch (location)
|
|
||||||
{
|
|
||||||
case NotificationLocation.Toast:
|
|
||||||
ShowToast(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case NotificationLocation.Chat:
|
|
||||||
ShowChat(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case NotificationLocation.Both:
|
|
||||||
ShowToast(msg);
|
|
||||||
ShowChat(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case NotificationLocation.Nowhere:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ShowToast(NotificationMessage msg)
|
|
||||||
{
|
|
||||||
Dalamud.Interface.ImGuiNotification.NotificationType dalamudType = msg.Type switch
|
|
||||||
{
|
|
||||||
NotificationType.Error => Dalamud.Interface.ImGuiNotification.NotificationType.Error,
|
|
||||||
NotificationType.Warning => Dalamud.Interface.ImGuiNotification.NotificationType.Warning,
|
|
||||||
NotificationType.Info => Dalamud.Interface.ImGuiNotification.NotificationType.Info,
|
|
||||||
_ => Dalamud.Interface.ImGuiNotification.NotificationType.Info
|
|
||||||
};
|
|
||||||
|
|
||||||
_notificationManager.AddNotification(new Notification()
|
|
||||||
{
|
|
||||||
Content = msg.Message ?? string.Empty,
|
|
||||||
Title = msg.Title,
|
|
||||||
Type = dalamudType,
|
|
||||||
Minimized = false,
|
|
||||||
InitialDuration = msg.TimeShownOnScreen ?? TimeSpan.FromSeconds(3)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LightlessSync.LightlessConfiguration.Models;
|
||||||
using LightlessSync.PlayerData.Pairs;
|
using LightlessSync.PlayerData.Pairs;
|
||||||
using LightlessSync.Services.Mediator;
|
using LightlessSync.Services.Mediator;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -11,16 +13,18 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
private readonly DalamudUtilService _dalamudUtil;
|
private readonly DalamudUtilService _dalamudUtil;
|
||||||
private readonly PairManager _pairManager;
|
private readonly PairManager _pairManager;
|
||||||
|
private readonly Lazy<WebAPI.ApiController> _apiController;
|
||||||
private readonly object _syncRoot = new();
|
private readonly object _syncRoot = new();
|
||||||
private readonly List<PairRequestEntry> _requests = [];
|
private readonly List<PairRequestEntry> _requests = [];
|
||||||
|
|
||||||
private static readonly TimeSpan Expiration = TimeSpan.FromMinutes(5);
|
private static readonly TimeSpan Expiration = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
public PairRequestService(ILogger<PairRequestService> logger, LightlessMediator mediator, DalamudUtilService dalamudUtil, PairManager pairManager)
|
public PairRequestService(ILogger<PairRequestService> logger, LightlessMediator mediator, DalamudUtilService dalamudUtil, PairManager pairManager, Lazy<WebAPI.ApiController> apiController)
|
||||||
: base(logger, mediator)
|
: base(logger, mediator)
|
||||||
{
|
{
|
||||||
_dalamudUtil = dalamudUtil;
|
_dalamudUtil = dalamudUtil;
|
||||||
_pairManager = pairManager;
|
_pairManager = pairManager;
|
||||||
|
_apiController = apiController;
|
||||||
|
|
||||||
Mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, _ =>
|
Mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, _ =>
|
||||||
{
|
{
|
||||||
@@ -183,6 +187,41 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
|
|||||||
return _requests.RemoveAll(r => now - r.ReceivedAt > Expiration) > 0;
|
return _requests.RemoveAll(r => now - r.ReceivedAt > Expiration) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void AcceptPairRequest(string hashedCid)
|
||||||
|
{
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _apiController.Value.TryPairWithContentId(hashedCid).ConfigureAwait(false);
|
||||||
|
RemoveRequest(hashedCid);
|
||||||
|
|
||||||
|
var display = ResolveDisplayName(hashedCid);
|
||||||
|
var displayText = string.IsNullOrEmpty(display) ? hashedCid : display;
|
||||||
|
Mediator.Publish(new NotificationMessage(
|
||||||
|
"Pair request accepted",
|
||||||
|
$"Sent a pair request back to {displayText}.",
|
||||||
|
NotificationType.Info,
|
||||||
|
TimeSpan.FromSeconds(3)));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Failed to accept pair request for {HashedCid}", hashedCid);
|
||||||
|
Mediator.Publish(new NotificationMessage(
|
||||||
|
"Failed to Accept Pair Request",
|
||||||
|
ex.Message,
|
||||||
|
NotificationType.Error,
|
||||||
|
TimeSpan.FromSeconds(5)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DeclinePairRequest(string hashedCid)
|
||||||
|
{
|
||||||
|
RemoveRequest(hashedCid);
|
||||||
|
Logger.LogDebug("Declined pair request from {HashedCid}", hashedCid);
|
||||||
|
}
|
||||||
|
|
||||||
private record struct PairRequestEntry(string HashedCid, string MessageTemplate, DateTime ReceivedAt);
|
private record struct PairRequestEntry(string HashedCid, string MessageTemplate, DateTime ReceivedAt);
|
||||||
|
|
||||||
public readonly record struct PairRequestDisplay(string HashedCid, string DisplayName, string Message, DateTime ReceivedAt);
|
public readonly record struct PairRequestDisplay(string HashedCid, string DisplayName, string Message, DateTime ReceivedAt);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Dalamud.Interface;
|
using Dalamud.Interface;
|
||||||
using Dalamud.Interface.ImGuiFileDialog;
|
using Dalamud.Interface.ImGuiFileDialog;
|
||||||
using Dalamud.Interface.Windowing;
|
using Dalamud.Interface.Windowing;
|
||||||
using LightlessSync.LightlessConfiguration;
|
using LightlessSync.LightlessConfiguration;
|
||||||
@@ -22,7 +22,8 @@ public sealed class UiService : DisposableMediatorSubscriberBase
|
|||||||
LightlessConfigService lightlessConfigService, WindowSystem windowSystem,
|
LightlessConfigService lightlessConfigService, WindowSystem windowSystem,
|
||||||
IEnumerable<WindowMediatorSubscriberBase> windows,
|
IEnumerable<WindowMediatorSubscriberBase> windows,
|
||||||
UiFactory uiFactory, FileDialogManager fileDialogManager,
|
UiFactory uiFactory, FileDialogManager fileDialogManager,
|
||||||
LightlessMediator lightlessMediator) : base(logger, lightlessMediator)
|
LightlessMediator lightlessMediator,
|
||||||
|
NotificationService notificationService) : base(logger, lightlessMediator)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_logger.LogTrace("Creating {type}", GetType().Name);
|
_logger.LogTrace("Creating {type}", GetType().Name);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using Dalamud.Bindings.ImGui;
|
using Dalamud.Bindings.ImGui;
|
||||||
using Dalamud.Interface;
|
using Dalamud.Interface;
|
||||||
using Dalamud.Interface.Utility;
|
using Dalamud.Interface.Utility;
|
||||||
@@ -87,7 +87,7 @@ public class CompactUi : WindowMediatorSubscriberBase
|
|||||||
IpcManager ipcManager,
|
IpcManager ipcManager,
|
||||||
BroadcastService broadcastService,
|
BroadcastService broadcastService,
|
||||||
CharacterAnalyzer characterAnalyzer,
|
CharacterAnalyzer characterAnalyzer,
|
||||||
PlayerPerformanceConfigService playerPerformanceConfig, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService) : base(logger, mediator, "###LightlessSyncMainUI", performanceCollectorService)
|
PlayerPerformanceConfigService playerPerformanceConfig, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService, NotificationService lightlessNotificationService) : base(logger, mediator, "###LightlessSyncMainUI", performanceCollectorService)
|
||||||
{
|
{
|
||||||
_uiSharedService = uiShared;
|
_uiSharedService = uiShared;
|
||||||
_configService = configService;
|
_configService = configService;
|
||||||
@@ -105,7 +105,7 @@ public class CompactUi : WindowMediatorSubscriberBase
|
|||||||
_renamePairTagUi = renameTagUi;
|
_renamePairTagUi = renameTagUi;
|
||||||
_ipcManager = ipcManager;
|
_ipcManager = ipcManager;
|
||||||
_broadcastService = broadcastService;
|
_broadcastService = broadcastService;
|
||||||
_tabMenu = new TopTabMenu(Mediator, _apiController, _pairManager, _uiSharedService, pairRequestService, dalamudUtilService);
|
_tabMenu = new TopTabMenu(Mediator, _apiController, _pairManager, _uiSharedService, pairRequestService, dalamudUtilService, lightlessNotificationService);
|
||||||
|
|
||||||
AllowPinning = true;
|
AllowPinning = true;
|
||||||
AllowClickthrough = false;
|
AllowClickthrough = false;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using System;
|
|
||||||
using Dalamud.Bindings.ImGui;
|
using Dalamud.Bindings.ImGui;
|
||||||
using Dalamud.Interface.Colors;
|
using Dalamud.Interface.Colors;
|
||||||
using LightlessSync.LightlessConfiguration;
|
using LightlessSync.LightlessConfiguration;
|
||||||
|
using LightlessSync.LightlessConfiguration.Models;
|
||||||
using LightlessSync.PlayerData.Handlers;
|
using LightlessSync.PlayerData.Handlers;
|
||||||
using LightlessSync.Services;
|
using LightlessSync.Services;
|
||||||
using LightlessSync.Services.Mediator;
|
using LightlessSync.Services.Mediator;
|
||||||
@@ -22,9 +22,12 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
|||||||
private readonly UiSharedService _uiShared;
|
private readonly UiSharedService _uiShared;
|
||||||
private readonly PairProcessingLimiter _pairProcessingLimiter;
|
private readonly PairProcessingLimiter _pairProcessingLimiter;
|
||||||
private readonly ConcurrentDictionary<GameObjectHandler, bool> _uploadingPlayers = new();
|
private readonly ConcurrentDictionary<GameObjectHandler, bool> _uploadingPlayers = new();
|
||||||
|
private readonly NotificationService _notificationService;
|
||||||
|
private bool _notificationDismissed = true;
|
||||||
|
|
||||||
public DownloadUi(ILogger<DownloadUi> logger, DalamudUtilService dalamudUtilService, LightlessConfigService configService,
|
public DownloadUi(ILogger<DownloadUi> logger, DalamudUtilService dalamudUtilService, LightlessConfigService configService,
|
||||||
PairProcessingLimiter pairProcessingLimiter, FileUploadManager fileTransferManager, LightlessMediator mediator, UiSharedService uiShared, PerformanceCollectorService performanceCollectorService)
|
PairProcessingLimiter pairProcessingLimiter, FileUploadManager fileTransferManager, LightlessMediator mediator, UiSharedService uiShared,
|
||||||
|
PerformanceCollectorService performanceCollectorService, NotificationService notificationService)
|
||||||
: base(logger, mediator, "Lightless Sync Downloads", performanceCollectorService)
|
: base(logger, mediator, "Lightless Sync Downloads", performanceCollectorService)
|
||||||
{
|
{
|
||||||
_dalamudUtilService = dalamudUtilService;
|
_dalamudUtilService = dalamudUtilService;
|
||||||
@@ -32,6 +35,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
|||||||
_pairProcessingLimiter = pairProcessingLimiter;
|
_pairProcessingLimiter = pairProcessingLimiter;
|
||||||
_fileTransferManager = fileTransferManager;
|
_fileTransferManager = fileTransferManager;
|
||||||
_uiShared = uiShared;
|
_uiShared = uiShared;
|
||||||
|
_notificationService = notificationService;
|
||||||
|
|
||||||
SizeConstraints = new WindowSizeConstraints()
|
SizeConstraints = new WindowSizeConstraints()
|
||||||
{
|
{
|
||||||
@@ -56,7 +60,14 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
|||||||
IsOpen = true;
|
IsOpen = true;
|
||||||
|
|
||||||
Mediator.Subscribe<DownloadStartedMessage>(this, (msg) => _currentDownloads[msg.DownloadId] = msg.DownloadStatus);
|
Mediator.Subscribe<DownloadStartedMessage>(this, (msg) => _currentDownloads[msg.DownloadId] = msg.DownloadStatus);
|
||||||
Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) => _currentDownloads.TryRemove(msg.DownloadId, out _));
|
Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) =>
|
||||||
|
{
|
||||||
|
_currentDownloads.TryRemove(msg.DownloadId, out _);
|
||||||
|
if (!_currentDownloads.Any())
|
||||||
|
{
|
||||||
|
_notificationService.DismissPairDownloadNotification();
|
||||||
|
}
|
||||||
|
});
|
||||||
Mediator.Subscribe<GposeStartMessage>(this, (_) => IsOpen = false);
|
Mediator.Subscribe<GposeStartMessage>(this, (_) => IsOpen = false);
|
||||||
Mediator.Subscribe<GposeEndMessage>(this, (_) => IsOpen = true);
|
Mediator.Subscribe<GposeEndMessage>(this, (_) => IsOpen = true);
|
||||||
Mediator.Subscribe<PlayerUploadingMessage>(this, (msg) =>
|
Mediator.Subscribe<PlayerUploadingMessage>(this, (msg) =>
|
||||||
@@ -77,19 +88,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
|||||||
if (_configService.Current.ShowTransferWindow)
|
if (_configService.Current.ShowTransferWindow)
|
||||||
{
|
{
|
||||||
var limiterSnapshot = _pairProcessingLimiter.GetSnapshot();
|
var limiterSnapshot = _pairProcessingLimiter.GetSnapshot();
|
||||||
if (limiterSnapshot.IsEnabled)
|
|
||||||
{
|
|
||||||
var queueColor = limiterSnapshot.Waiting > 0 ? ImGuiColors.DalamudYellow : ImGuiColors.DalamudGrey;
|
|
||||||
var queueText = $"Pair queue {limiterSnapshot.InFlight}/{limiterSnapshot.Limit}";
|
|
||||||
queueText += limiterSnapshot.Waiting > 0 ? $" ({limiterSnapshot.Waiting} waiting, {limiterSnapshot.Remaining} free)" : $" ({limiterSnapshot.Remaining} free)";
|
|
||||||
UiSharedService.DrawOutlinedFont(queueText, queueColor, new Vector4(0, 0, 0, 255), 1);
|
|
||||||
ImGui.NewLine();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
UiSharedService.DrawOutlinedFont("Pair apply limiter disabled", ImGuiColors.DalamudGrey, new Vector4(0, 0, 0, 255), 1);
|
|
||||||
ImGui.NewLine();
|
|
||||||
}
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_fileTransferManager.IsUploading)
|
if (_fileTransferManager.IsUploading)
|
||||||
@@ -122,28 +121,64 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (var item in _currentDownloads.ToList())
|
// Check if download notifications are enabled (not set to TextOverlay)
|
||||||
|
var useNotifications = _configService.Current.UseLightlessNotifications
|
||||||
|
? _configService.Current.LightlessDownloadNotification != NotificationLocation.TextOverlay
|
||||||
|
: _configService.Current.UseNotificationsForDownloads;
|
||||||
|
|
||||||
|
if (useNotifications)
|
||||||
{
|
{
|
||||||
var dlSlot = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForSlot);
|
// Use notification system
|
||||||
var dlQueue = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForQueue);
|
if (_currentDownloads.Any())
|
||||||
var dlProg = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Downloading);
|
{
|
||||||
var dlDecomp = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Decompressing);
|
UpdateDownloadNotification(limiterSnapshot);
|
||||||
var totalFiles = item.Value.Sum(c => c.Value.TotalFiles);
|
_notificationDismissed = false;
|
||||||
var transferredFiles = item.Value.Sum(c => c.Value.TransferredFiles);
|
}
|
||||||
var totalBytes = item.Value.Sum(c => c.Value.TotalBytes);
|
else if (!_notificationDismissed)
|
||||||
var transferredBytes = item.Value.Sum(c => c.Value.TransferredBytes);
|
{
|
||||||
|
_notificationService.DismissPairDownloadNotification();
|
||||||
|
_notificationDismissed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (limiterSnapshot.IsEnabled)
|
||||||
|
{
|
||||||
|
var queueColor = limiterSnapshot.Waiting > 0 ? ImGuiColors.DalamudYellow : ImGuiColors.DalamudGrey;
|
||||||
|
var queueText = $"Pair queue {limiterSnapshot.InFlight}/{limiterSnapshot.Limit}";
|
||||||
|
queueText += limiterSnapshot.Waiting > 0 ? $" ({limiterSnapshot.Waiting} waiting, {limiterSnapshot.Remaining} free)" : $" ({limiterSnapshot.Remaining} free)";
|
||||||
|
UiSharedService.DrawOutlinedFont(queueText, queueColor, new Vector4(0, 0, 0, 255), 1);
|
||||||
|
ImGui.NewLine();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
UiSharedService.DrawOutlinedFont("Pair apply limiter disabled", ImGuiColors.DalamudGrey, new Vector4(0, 0, 0, 255), 1);
|
||||||
|
ImGui.NewLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in _currentDownloads.ToList())
|
||||||
|
{
|
||||||
|
var dlSlot = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForSlot);
|
||||||
|
var dlQueue = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForQueue);
|
||||||
|
var dlProg = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Downloading);
|
||||||
|
var dlDecomp = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Decompressing);
|
||||||
|
var totalFiles = item.Value.Sum(c => c.Value.TotalFiles);
|
||||||
|
var transferredFiles = item.Value.Sum(c => c.Value.TransferredFiles);
|
||||||
|
var totalBytes = item.Value.Sum(c => c.Value.TotalBytes);
|
||||||
|
var transferredBytes = item.Value.Sum(c => c.Value.TransferredBytes);
|
||||||
|
|
||||||
UiSharedService.DrawOutlinedFont($"▼", ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1);
|
UiSharedService.DrawOutlinedFont($"▼", ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1);
|
||||||
ImGui.SameLine();
|
ImGui.SameLine();
|
||||||
var xDistance = ImGui.GetCursorPosX();
|
var xDistance = ImGui.GetCursorPosX();
|
||||||
UiSharedService.DrawOutlinedFont(
|
UiSharedService.DrawOutlinedFont(
|
||||||
$"{item.Key.Name} [W:{dlSlot}/Q:{dlQueue}/P:{dlProg}/D:{dlDecomp}]",
|
$"{item.Key.Name} [W:{dlSlot}/Q:{dlQueue}/P:{dlProg}/D:{dlDecomp}]",
|
||||||
ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1);
|
ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1);
|
||||||
ImGui.NewLine();
|
ImGui.NewLine();
|
||||||
ImGui.SameLine(xDistance);
|
ImGui.SameLine(xDistance);
|
||||||
UiSharedService.DrawOutlinedFont(
|
UiSharedService.DrawOutlinedFont(
|
||||||
$"{transferredFiles}/{totalFiles} ({UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)})",
|
$"{transferredFiles}/{totalFiles} ({UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)})",
|
||||||
ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1);
|
ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -262,4 +297,40 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
|||||||
MaximumSize = new Vector2(300, maxHeight),
|
MaximumSize = new Vector2(300, maxHeight),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void UpdateDownloadNotification(PairProcessingLimiterSnapshot limiterSnapshot)
|
||||||
|
{
|
||||||
|
var downloadStatus = new List<(string playerName, float progress, string status)>();
|
||||||
|
|
||||||
|
foreach (var item in _currentDownloads.ToList())
|
||||||
|
{
|
||||||
|
var dlSlot = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForSlot);
|
||||||
|
var dlQueue = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForQueue);
|
||||||
|
var dlProg = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Downloading);
|
||||||
|
var dlDecomp = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Decompressing);
|
||||||
|
var totalFiles = item.Value.Sum(c => c.Value.TotalFiles);
|
||||||
|
var transferredFiles = item.Value.Sum(c => c.Value.TransferredFiles);
|
||||||
|
var totalBytes = item.Value.Sum(c => c.Value.TotalBytes);
|
||||||
|
var transferredBytes = item.Value.Sum(c => c.Value.TransferredBytes);
|
||||||
|
|
||||||
|
var progress = totalBytes > 0 ? (float)transferredBytes / totalBytes : 0f;
|
||||||
|
|
||||||
|
string status;
|
||||||
|
if (dlDecomp > 0) status = "decompressing";
|
||||||
|
else if (dlProg > 0) status = "downloading";
|
||||||
|
else if (dlQueue > 0) status = "queued";
|
||||||
|
else if (dlSlot > 0) status = "waiting";
|
||||||
|
else status = "completed";
|
||||||
|
|
||||||
|
downloadStatus.Add((item.Key.Name, progress, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass queue waiting count separately, show notification if there are downloads or queue items
|
||||||
|
var queueWaiting = limiterSnapshot.IsEnabled ? limiterSnapshot.Waiting : 0;
|
||||||
|
if (downloadStatus.Any() || queueWaiting > 0)
|
||||||
|
{
|
||||||
|
_notificationService.ShowPairDownloadNotification(downloadStatus, queueWaiting);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
598
LightlessSync/UI/LightlessNotificationUI.cs
Normal file
598
LightlessSync/UI/LightlessNotificationUI.cs
Normal file
@@ -0,0 +1,598 @@
|
|||||||
|
using Dalamud.Interface;
|
||||||
|
using Dalamud.Interface.Colors;
|
||||||
|
using Dalamud.Interface.Utility;
|
||||||
|
using Dalamud.Interface.Utility.Raii;
|
||||||
|
using Dalamud.Interface.Windowing;
|
||||||
|
using LightlessSync.LightlessConfiguration;
|
||||||
|
using LightlessSync.LightlessConfiguration.Models;
|
||||||
|
using LightlessSync.Services;
|
||||||
|
using LightlessSync.Services.Mediator;
|
||||||
|
using LightlessSync.UI.Models;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
using System.Numerics;
|
||||||
|
using Dalamud.Bindings.ImGui;
|
||||||
|
|
||||||
|
namespace LightlessSync.UI;
|
||||||
|
|
||||||
|
public class LightlessNotificationUI : WindowMediatorSubscriberBase
|
||||||
|
{
|
||||||
|
private const float NotificationMinHeight = 60f;
|
||||||
|
private const float NotificationMaxHeight = 250f;
|
||||||
|
private const float WindowPaddingOffset = 6f;
|
||||||
|
private const float SlideAnimationDistance = 100f;
|
||||||
|
private const float OutAnimationSpeedMultiplier = 0.7f;
|
||||||
|
|
||||||
|
private readonly List<LightlessNotification> _notifications = new();
|
||||||
|
private readonly object _notificationLock = new();
|
||||||
|
private readonly LightlessConfigService _configService;
|
||||||
|
|
||||||
|
public LightlessNotificationUI(ILogger<LightlessNotificationUI> logger, LightlessMediator mediator, PerformanceCollectorService performanceCollector, LightlessConfigService configService)
|
||||||
|
: base(logger, mediator, "Lightless Notifications##LightlessNotifications", performanceCollector)
|
||||||
|
{
|
||||||
|
_configService = configService;
|
||||||
|
Flags = ImGuiWindowFlags.NoDecoration |
|
||||||
|
ImGuiWindowFlags.NoMove |
|
||||||
|
ImGuiWindowFlags.NoResize |
|
||||||
|
ImGuiWindowFlags.NoSavedSettings |
|
||||||
|
ImGuiWindowFlags.NoFocusOnAppearing |
|
||||||
|
ImGuiWindowFlags.NoNav |
|
||||||
|
ImGuiWindowFlags.NoBackground |
|
||||||
|
ImGuiWindowFlags.NoCollapse |
|
||||||
|
ImGuiWindowFlags.AlwaysAutoResize;
|
||||||
|
|
||||||
|
PositionCondition = ImGuiCond.Always;
|
||||||
|
|
||||||
|
Size = new Vector2(_configService.Current.NotificationWidth, 100);
|
||||||
|
SizeCondition = ImGuiCond.FirstUseEver;
|
||||||
|
IsOpen = false;
|
||||||
|
RespectCloseHotkey = false;
|
||||||
|
DisableWindowSounds = true;
|
||||||
|
|
||||||
|
Mediator.Subscribe<LightlessNotificationMessage>(this, HandleNotificationMessage);
|
||||||
|
Mediator.Subscribe<LightlessNotificationDismissMessage>(this, HandleNotificationDismissMessage);
|
||||||
|
}
|
||||||
|
private void HandleNotificationMessage(LightlessNotificationMessage message) =>
|
||||||
|
AddNotification(message.Notification);
|
||||||
|
|
||||||
|
private void HandleNotificationDismissMessage(LightlessNotificationDismissMessage message) =>
|
||||||
|
RemoveNotification(message.NotificationId);
|
||||||
|
|
||||||
|
public void AddNotification(LightlessNotification notification)
|
||||||
|
{
|
||||||
|
lock (_notificationLock)
|
||||||
|
{
|
||||||
|
var existingNotification = _notifications.FirstOrDefault(n => n.Id == notification.Id);
|
||||||
|
if (existingNotification != null)
|
||||||
|
{
|
||||||
|
UpdateExistingNotification(existingNotification, notification);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_notifications.Add(notification);
|
||||||
|
_logger.LogDebug("Added new notification: {Title}", notification.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsOpen) IsOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateExistingNotification(LightlessNotification existing, LightlessNotification updated)
|
||||||
|
{
|
||||||
|
existing.Message = updated.Message;
|
||||||
|
existing.Progress = updated.Progress;
|
||||||
|
existing.ShowProgress = updated.ShowProgress;
|
||||||
|
existing.Title = updated.Title;
|
||||||
|
_logger.LogDebug("Updated existing notification: {Title}", updated.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveNotification(string id)
|
||||||
|
{
|
||||||
|
lock (_notificationLock)
|
||||||
|
{
|
||||||
|
var notification = _notifications.FirstOrDefault(n => n.Id == id);
|
||||||
|
if (notification != null)
|
||||||
|
{
|
||||||
|
StartOutAnimation(notification);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartOutAnimation(LightlessNotification notification)
|
||||||
|
{
|
||||||
|
notification.IsAnimatingOut = true;
|
||||||
|
notification.IsAnimatingIn = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void DrawInternal()
|
||||||
|
{
|
||||||
|
ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero);
|
||||||
|
|
||||||
|
lock (_notificationLock)
|
||||||
|
{
|
||||||
|
UpdateNotifications();
|
||||||
|
|
||||||
|
if (_notifications.Count == 0)
|
||||||
|
{
|
||||||
|
ImGui.PopStyleVar();
|
||||||
|
IsOpen = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var viewport = ImGui.GetMainViewport();
|
||||||
|
Position = CalculateWindowPosition(viewport);
|
||||||
|
DrawAllNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui.PopStyleVar();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Vector2 CalculateWindowPosition(ImGuiViewportPtr viewport)
|
||||||
|
{
|
||||||
|
var x = viewport.WorkPos.X + viewport.WorkSize.X -
|
||||||
|
_configService.Current.NotificationWidth -
|
||||||
|
_configService.Current.NotificationOffsetX -
|
||||||
|
WindowPaddingOffset;
|
||||||
|
var y = viewport.WorkPos.Y + _configService.Current.NotificationOffsetY;
|
||||||
|
return new Vector2(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawAllNotifications()
|
||||||
|
{
|
||||||
|
for (int i = 0; i < _notifications.Count; i++)
|
||||||
|
{
|
||||||
|
DrawNotification(_notifications[i], i);
|
||||||
|
|
||||||
|
if (i < _notifications.Count - 1)
|
||||||
|
{
|
||||||
|
ImGui.Dummy(new Vector2(0, _configService.Current.NotificationSpacing));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateNotifications()
|
||||||
|
{
|
||||||
|
var deltaTime = ImGui.GetIO().DeltaTime;
|
||||||
|
EnforceMaxNotificationLimit();
|
||||||
|
UpdateAnimationsAndRemoveExpired(deltaTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnforceMaxNotificationLimit()
|
||||||
|
{
|
||||||
|
var maxNotifications = _configService.Current.MaxSimultaneousNotifications;
|
||||||
|
while (_notifications.Count(n => !n.IsAnimatingOut) > maxNotifications)
|
||||||
|
{
|
||||||
|
var oldestNotification = _notifications
|
||||||
|
.Where(n => !n.IsAnimatingOut)
|
||||||
|
.OrderBy(n => n.CreatedAt)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (oldestNotification != null)
|
||||||
|
{
|
||||||
|
StartOutAnimation(oldestNotification);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateAnimationsAndRemoveExpired(float deltaTime)
|
||||||
|
{
|
||||||
|
for (int i = _notifications.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var notification = _notifications[i];
|
||||||
|
UpdateNotificationAnimation(notification, deltaTime);
|
||||||
|
|
||||||
|
if (ShouldRemoveNotification(notification))
|
||||||
|
{
|
||||||
|
_notifications.RemoveAt(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateNotificationAnimation(LightlessNotification notification, float deltaTime)
|
||||||
|
{
|
||||||
|
if (notification.IsAnimatingIn && notification.AnimationProgress < 1f)
|
||||||
|
{
|
||||||
|
notification.AnimationProgress = Math.Min(1f,
|
||||||
|
notification.AnimationProgress + deltaTime * _configService.Current.NotificationAnimationSpeed);
|
||||||
|
}
|
||||||
|
else if (notification.IsAnimatingOut && notification.AnimationProgress > 0f)
|
||||||
|
{
|
||||||
|
notification.AnimationProgress = Math.Max(0f,
|
||||||
|
notification.AnimationProgress - deltaTime * _configService.Current.NotificationAnimationSpeed * OutAnimationSpeedMultiplier);
|
||||||
|
}
|
||||||
|
else if (!notification.IsAnimatingOut && !notification.IsDismissed)
|
||||||
|
{
|
||||||
|
notification.IsAnimatingIn = false;
|
||||||
|
|
||||||
|
if (notification.IsExpired)
|
||||||
|
{
|
||||||
|
StartOutAnimation(notification);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ShouldRemoveNotification(LightlessNotification notification) =>
|
||||||
|
notification.IsAnimatingOut && notification.AnimationProgress <= 0.01f;
|
||||||
|
|
||||||
|
private void DrawNotification(LightlessNotification notification, int index)
|
||||||
|
{
|
||||||
|
var alpha = notification.AnimationProgress;
|
||||||
|
if (alpha <= 0f) return;
|
||||||
|
|
||||||
|
var slideOffset = (1f - alpha) * SlideAnimationDistance;
|
||||||
|
var originalCursorPos = ImGui.GetCursorPos();
|
||||||
|
ImGui.SetCursorPosX(originalCursorPos.X + slideOffset);
|
||||||
|
|
||||||
|
var notificationHeight = CalculateNotificationHeight(notification);
|
||||||
|
var notificationWidth = _configService.Current.NotificationWidth - slideOffset;
|
||||||
|
|
||||||
|
ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero);
|
||||||
|
|
||||||
|
using var child = ImRaii.Child($"notification_{notification.Id}",
|
||||||
|
new Vector2(notificationWidth, notificationHeight),
|
||||||
|
false, ImGuiWindowFlags.NoScrollbar);
|
||||||
|
|
||||||
|
if (child.Success)
|
||||||
|
{
|
||||||
|
DrawNotificationContent(notification, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui.PopStyleVar();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawNotificationContent(LightlessNotification notification, float alpha)
|
||||||
|
{
|
||||||
|
var drawList = ImGui.GetWindowDrawList();
|
||||||
|
var windowPos = ImGui.GetWindowPos();
|
||||||
|
var windowSize = ImGui.GetWindowSize();
|
||||||
|
|
||||||
|
var bgColor = CalculateBackgroundColor(alpha, ImGui.IsWindowHovered());
|
||||||
|
var accentColor = GetNotificationAccentColor(notification.Type);
|
||||||
|
accentColor.W *= alpha;
|
||||||
|
|
||||||
|
DrawShadow(drawList, windowPos, windowSize, alpha);
|
||||||
|
HandleClickToDismiss(notification);
|
||||||
|
DrawBackground(drawList, windowPos, windowSize, bgColor);
|
||||||
|
DrawAccentBar(drawList, windowPos, windowSize, accentColor);
|
||||||
|
DrawDurationProgressBar(notification, alpha, windowPos, windowSize, drawList);
|
||||||
|
DrawNotificationText(notification, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Vector4 CalculateBackgroundColor(float alpha, bool isHovered)
|
||||||
|
{
|
||||||
|
var baseOpacity = _configService.Current.NotificationOpacity;
|
||||||
|
var finalOpacity = baseOpacity * alpha;
|
||||||
|
var bgColor = new Vector4(30f/255f, 30f/255f, 30f/255f, finalOpacity);
|
||||||
|
|
||||||
|
if (isHovered)
|
||||||
|
{
|
||||||
|
bgColor *= 1.1f;
|
||||||
|
bgColor.W = Math.Min(bgColor.W, 0.98f);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bgColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawShadow(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, float alpha)
|
||||||
|
{
|
||||||
|
var shadowOffset = new Vector2(1f, 1f);
|
||||||
|
var shadowColor = new Vector4(0f, 0f, 0f, 0.4f * alpha);
|
||||||
|
drawList.AddRectFilled(
|
||||||
|
windowPos + shadowOffset,
|
||||||
|
windowPos + windowSize + shadowOffset,
|
||||||
|
ImGui.ColorConvertFloat4ToU32(shadowColor),
|
||||||
|
3f
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleClickToDismiss(LightlessNotification notification)
|
||||||
|
{
|
||||||
|
if (ImGui.IsWindowHovered() &&
|
||||||
|
_configService.Current.DismissNotificationOnClick &&
|
||||||
|
!notification.Actions.Any() &&
|
||||||
|
ImGui.IsMouseClicked(ImGuiMouseButton.Left))
|
||||||
|
{
|
||||||
|
notification.IsDismissed = true;
|
||||||
|
StartOutAnimation(notification);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawBackground(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, Vector4 bgColor)
|
||||||
|
{
|
||||||
|
drawList.AddRectFilled(
|
||||||
|
windowPos,
|
||||||
|
windowPos + windowSize,
|
||||||
|
ImGui.ColorConvertFloat4ToU32(bgColor),
|
||||||
|
3f
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawAccentBar(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, Vector4 accentColor)
|
||||||
|
{
|
||||||
|
var accentWidth = _configService.Current.NotificationAccentBarWidth;
|
||||||
|
if (accentWidth > 0f)
|
||||||
|
{
|
||||||
|
drawList.AddRectFilled(
|
||||||
|
windowPos,
|
||||||
|
windowPos + new Vector2(accentWidth, windowSize.Y),
|
||||||
|
ImGui.ColorConvertFloat4ToU32(accentColor),
|
||||||
|
3f
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawDurationProgressBar(LightlessNotification notification, float alpha, Vector2 windowPos, Vector2 windowSize, ImDrawListPtr drawList)
|
||||||
|
{
|
||||||
|
var progress = CalculateProgress(notification);
|
||||||
|
var progressBarColor = UIColors.Get("LightlessBlue");
|
||||||
|
var progressHeight = 2f;
|
||||||
|
var progressY = windowPos.Y + windowSize.Y - progressHeight;
|
||||||
|
var progressWidth = windowSize.X * progress;
|
||||||
|
|
||||||
|
DrawProgressBackground(drawList, windowPos, windowSize, progressY, progressHeight, progressBarColor, alpha);
|
||||||
|
|
||||||
|
if (progress > 0)
|
||||||
|
{
|
||||||
|
DrawProgressForeground(drawList, windowPos, progressY, progressHeight, progressWidth, progressBarColor, alpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private float CalculateProgress(LightlessNotification notification)
|
||||||
|
{
|
||||||
|
if (notification.Type == NotificationType.Download && notification.ShowProgress)
|
||||||
|
{
|
||||||
|
return Math.Clamp(notification.Progress, 0f, 1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
var elapsed = DateTime.UtcNow - notification.CreatedAt;
|
||||||
|
return Math.Min(1.0f, (float)(elapsed.TotalSeconds / notification.Duration.TotalSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawProgressBackground(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, float progressY, float progressHeight, Vector4 progressBarColor, float alpha)
|
||||||
|
{
|
||||||
|
var bgProgressColor = new Vector4(progressBarColor.X * 0.3f, progressBarColor.Y * 0.3f, progressBarColor.Z * 0.3f, 0.5f * alpha);
|
||||||
|
drawList.AddRectFilled(
|
||||||
|
new Vector2(windowPos.X, progressY),
|
||||||
|
new Vector2(windowPos.X + windowSize.X, progressY + progressHeight),
|
||||||
|
ImGui.ColorConvertFloat4ToU32(bgProgressColor),
|
||||||
|
0f
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawProgressForeground(ImDrawListPtr drawList, Vector2 windowPos, float progressY, float progressHeight, float progressWidth, Vector4 progressBarColor, float alpha)
|
||||||
|
{
|
||||||
|
var progressColor = progressBarColor;
|
||||||
|
progressColor.W *= alpha;
|
||||||
|
drawList.AddRectFilled(
|
||||||
|
new Vector2(windowPos.X, progressY),
|
||||||
|
new Vector2(windowPos.X + progressWidth, progressY + progressHeight),
|
||||||
|
ImGui.ColorConvertFloat4ToU32(progressColor),
|
||||||
|
0f
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawNotificationText(LightlessNotification notification, float alpha)
|
||||||
|
{
|
||||||
|
var padding = new Vector2(10f, 6f);
|
||||||
|
var contentPos = new Vector2(padding.X, padding.Y);
|
||||||
|
var windowSize = ImGui.GetWindowSize();
|
||||||
|
var contentSize = new Vector2(windowSize.X - padding.X, windowSize.Y - padding.Y * 2);
|
||||||
|
|
||||||
|
ImGui.SetCursorPos(contentPos);
|
||||||
|
|
||||||
|
var titleHeight = DrawTitle(notification, contentSize.X, alpha);
|
||||||
|
DrawMessage(notification, contentPos, contentSize.X, titleHeight, alpha);
|
||||||
|
|
||||||
|
if (notification.Actions.Count > 0)
|
||||||
|
{
|
||||||
|
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + ImGui.GetStyle().ItemSpacing.Y);
|
||||||
|
ImGui.SetCursorPosX(contentPos.X);
|
||||||
|
DrawNotificationActions(notification, contentSize.X, alpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private float DrawTitle(LightlessNotification notification, float contentWidth, float alpha)
|
||||||
|
{
|
||||||
|
using (ImRaii.PushColor(ImGuiCol.Text, new Vector4(1f, 1f, 1f, alpha)))
|
||||||
|
{
|
||||||
|
ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + contentWidth);
|
||||||
|
var titleStartY = ImGui.GetCursorPosY();
|
||||||
|
|
||||||
|
var titleText = _configService.Current.ShowNotificationTimestamp
|
||||||
|
? $"[{notification.CreatedAt.ToLocalTime():HH:mm:ss}] {notification.Title}"
|
||||||
|
: notification.Title;
|
||||||
|
|
||||||
|
ImGui.TextWrapped(titleText);
|
||||||
|
var titleHeight = ImGui.GetCursorPosY() - titleStartY;
|
||||||
|
ImGui.PopTextWrapPos();
|
||||||
|
return titleHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawMessage(LightlessNotification notification, Vector2 contentPos, float contentWidth, float titleHeight, float alpha)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(notification.Message)) return;
|
||||||
|
|
||||||
|
ImGui.SetCursorPos(contentPos + new Vector2(0f, titleHeight + 4f));
|
||||||
|
ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + contentWidth);
|
||||||
|
using (ImRaii.PushColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.9f, alpha)))
|
||||||
|
{
|
||||||
|
ImGui.TextWrapped(notification.Message);
|
||||||
|
}
|
||||||
|
ImGui.PopTextWrapPos();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawNotificationActions(LightlessNotification notification, float availableWidth, float alpha)
|
||||||
|
{
|
||||||
|
var buttonSpacing = 8f;
|
||||||
|
var rightPadding = 10f;
|
||||||
|
var usableWidth = availableWidth - rightPadding;
|
||||||
|
var totalSpacing = (notification.Actions.Count - 1) * buttonSpacing;
|
||||||
|
var buttonWidth = (usableWidth - totalSpacing) / notification.Actions.Count;
|
||||||
|
|
||||||
|
_logger.LogDebug("Drawing {ActionCount} notification actions, buttonWidth: {ButtonWidth}, availableWidth: {AvailableWidth}",
|
||||||
|
notification.Actions.Count, buttonWidth, availableWidth);
|
||||||
|
|
||||||
|
var startCursorPos = ImGui.GetCursorPos();
|
||||||
|
|
||||||
|
for (int i = 0; i < notification.Actions.Count; i++)
|
||||||
|
{
|
||||||
|
var action = notification.Actions[i];
|
||||||
|
|
||||||
|
if (i > 0)
|
||||||
|
{
|
||||||
|
ImGui.SameLine();
|
||||||
|
var currentX = startCursorPos.X + i * (buttonWidth + buttonSpacing);
|
||||||
|
ImGui.SetCursorPosX(currentX);
|
||||||
|
}
|
||||||
|
DrawActionButton(action, notification, alpha, buttonWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawActionButton(LightlessNotificationAction action, LightlessNotification notification, float alpha, float buttonWidth)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Drawing action button: {ActionId} - {ActionLabel}, width: {ButtonWidth}", action.Id, action.Label, buttonWidth);
|
||||||
|
|
||||||
|
var buttonColor = action.Color;
|
||||||
|
buttonColor.W *= alpha;
|
||||||
|
|
||||||
|
var hoveredColor = buttonColor * 1.1f;
|
||||||
|
hoveredColor.W = buttonColor.W;
|
||||||
|
|
||||||
|
var activeColor = buttonColor * 0.9f;
|
||||||
|
activeColor.W = buttonColor.W;
|
||||||
|
|
||||||
|
using (ImRaii.PushColor(ImGuiCol.Button, buttonColor))
|
||||||
|
using (ImRaii.PushColor(ImGuiCol.ButtonHovered, hoveredColor))
|
||||||
|
using (ImRaii.PushColor(ImGuiCol.ButtonActive, activeColor))
|
||||||
|
using (ImRaii.PushColor(ImGuiCol.Text, new Vector4(1f, 1f, 1f, alpha)))
|
||||||
|
{
|
||||||
|
var buttonPressed = false;
|
||||||
|
|
||||||
|
if (action.Icon != FontAwesomeIcon.None)
|
||||||
|
{
|
||||||
|
buttonPressed = DrawIconTextButton(action.Icon, action.Label, buttonWidth, alpha);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
buttonPressed = ImGui.Button(action.Label, new Vector2(buttonWidth, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Button {ActionId} pressed: {ButtonPressed}", action.Id, buttonPressed);
|
||||||
|
|
||||||
|
if (buttonPressed)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Executing action: {ActionId}", action.Id);
|
||||||
|
action.OnClick(notification);
|
||||||
|
_logger.LogDebug("Action executed successfully: {ActionId}", action.Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error executing notification action: {ActionId}", action.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool DrawIconTextButton(FontAwesomeIcon icon, string text, float width, float alpha)
|
||||||
|
{
|
||||||
|
var drawList = ImGui.GetWindowDrawList();
|
||||||
|
var cursorPos = ImGui.GetCursorScreenPos();
|
||||||
|
var frameHeight = ImGui.GetFrameHeight();
|
||||||
|
|
||||||
|
Vector2 iconSize;
|
||||||
|
using (ImRaii.PushFont(UiBuilder.IconFont))
|
||||||
|
{
|
||||||
|
iconSize = ImGui.CalcTextSize(icon.ToIconString());
|
||||||
|
}
|
||||||
|
|
||||||
|
var textSize = ImGui.CalcTextSize(text);
|
||||||
|
var spacing = 3f * ImGuiHelpers.GlobalScale;
|
||||||
|
var totalTextWidth = iconSize.X + spacing + textSize.X;
|
||||||
|
|
||||||
|
var buttonPressed = ImGui.InvisibleButton($"btn_{icon}_{text}", new Vector2(width, frameHeight));
|
||||||
|
|
||||||
|
var buttonMin = ImGui.GetItemRectMin();
|
||||||
|
var buttonMax = ImGui.GetItemRectMax();
|
||||||
|
var buttonSize = buttonMax - buttonMin;
|
||||||
|
|
||||||
|
var buttonColor = ImGui.GetColorU32(ImGuiCol.Button);
|
||||||
|
if (ImGui.IsItemHovered())
|
||||||
|
buttonColor = ImGui.GetColorU32(ImGuiCol.ButtonHovered);
|
||||||
|
if (ImGui.IsItemActive())
|
||||||
|
buttonColor = ImGui.GetColorU32(ImGuiCol.ButtonActive);
|
||||||
|
|
||||||
|
drawList.AddRectFilled(buttonMin, buttonMax, buttonColor, 3f);
|
||||||
|
|
||||||
|
var iconPos = buttonMin + new Vector2((buttonSize.X - totalTextWidth) / 2f, (buttonSize.Y - iconSize.Y) / 2f);
|
||||||
|
var textPos = iconPos + new Vector2(iconSize.X + spacing, (iconSize.Y - textSize.Y) / 2f);
|
||||||
|
|
||||||
|
var textColor = ImGui.GetColorU32(ImGuiCol.Text);
|
||||||
|
|
||||||
|
// Draw icon
|
||||||
|
using (ImRaii.PushFont(UiBuilder.IconFont))
|
||||||
|
{
|
||||||
|
drawList.AddText(iconPos, textColor, icon.ToIconString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw text
|
||||||
|
drawList.AddText(textPos, textColor, text);
|
||||||
|
|
||||||
|
return buttonPressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private float CalculateNotificationHeight(LightlessNotification notification)
|
||||||
|
{
|
||||||
|
var contentWidth = _configService.Current.NotificationWidth - 35f;
|
||||||
|
var height = 12f;
|
||||||
|
|
||||||
|
height += CalculateTitleHeight(notification, contentWidth);
|
||||||
|
height += CalculateMessageHeight(notification, contentWidth);
|
||||||
|
|
||||||
|
if (notification.ShowProgress)
|
||||||
|
{
|
||||||
|
height += 12f;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.Actions.Count > 0)
|
||||||
|
{
|
||||||
|
height += ImGui.GetStyle().ItemSpacing.Y;
|
||||||
|
height += ImGui.GetFrameHeight();
|
||||||
|
height += 12f;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.Clamp(height, NotificationMinHeight, NotificationMaxHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private float CalculateTitleHeight(LightlessNotification notification, float contentWidth)
|
||||||
|
{
|
||||||
|
var titleText = _configService.Current.ShowNotificationTimestamp
|
||||||
|
? $"[{notification.CreatedAt.ToLocalTime():HH:mm:ss}] {notification.Title}"
|
||||||
|
: notification.Title;
|
||||||
|
|
||||||
|
return ImGui.CalcTextSize(titleText, true, contentWidth).Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
private float CalculateMessageHeight(LightlessNotification notification, float contentWidth)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(notification.Message)) return 0f;
|
||||||
|
|
||||||
|
var messageHeight = ImGui.CalcTextSize(notification.Message, true, contentWidth).Y;
|
||||||
|
return 4f + messageHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Vector4 GetNotificationAccentColor(NotificationType type)
|
||||||
|
{
|
||||||
|
return type switch
|
||||||
|
{
|
||||||
|
NotificationType.Info => UIColors.Get("LightlessPurple"),
|
||||||
|
NotificationType.Warning => UIColors.Get("LightlessYellow"),
|
||||||
|
NotificationType.Error => UIColors.Get("DimRed"),
|
||||||
|
NotificationType.PairRequest => UIColors.Get("LightlessBlue"),
|
||||||
|
NotificationType.Download => UIColors.Get("LightlessGreen"),
|
||||||
|
_ => UIColors.Get("LightlessPurple")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
32
LightlessSync/UI/Models/LightlessNotification.cs
Normal file
32
LightlessSync/UI/Models/LightlessNotification.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
using Dalamud.Interface;
|
||||||
|
using LightlessSync.LightlessConfiguration.Models;
|
||||||
|
using System.Numerics;
|
||||||
|
namespace LightlessSync.UI.Models;
|
||||||
|
public class LightlessNotification
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public NotificationType Type { get; set; } = NotificationType.Info;
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public TimeSpan Duration { get; set; } = TimeSpan.FromSeconds(5);
|
||||||
|
public bool IsExpired => DateTime.UtcNow - CreatedAt > Duration;
|
||||||
|
public bool IsDismissed { get; set; } = false;
|
||||||
|
public List<LightlessNotificationAction> Actions { get; set; } = new();
|
||||||
|
public bool ShowProgress { get; set; } = false;
|
||||||
|
public float Progress { get; set; } = 0f;
|
||||||
|
public float AnimationProgress { get; set; } = 0f;
|
||||||
|
public bool IsAnimatingIn { get; set; } = true;
|
||||||
|
public bool IsAnimatingOut { get; set; } = false;
|
||||||
|
public uint? SoundEffectId { get; set; } = null;
|
||||||
|
}
|
||||||
|
public class LightlessNotificationAction
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
public FontAwesomeIcon Icon { get; set; } = FontAwesomeIcon.None;
|
||||||
|
public Vector4 Color { get; set; } = Vector4.One;
|
||||||
|
public Action<LightlessNotification> OnClick { get; set; } = _ => { };
|
||||||
|
public bool IsPrimary { get; set; } = false;
|
||||||
|
public bool IsDestructive { get; set; } = false;
|
||||||
|
}
|
||||||
72
LightlessSync/UI/Models/NotificationSounds.cs
Normal file
72
LightlessSync/UI/Models/NotificationSounds.cs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
using LightlessSync.LightlessConfiguration.Models;
|
||||||
|
|
||||||
|
namespace LightlessSync.UI.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Common FFXIV <se.#> sound effect IDs for notifications.
|
||||||
|
/// These correspond to the same sound IDs used in macros (1–16).
|
||||||
|
/// </summary>
|
||||||
|
public static class NotificationSounds
|
||||||
|
{
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Base <se.#> IDs (1–16)
|
||||||
|
// https://ffxiv.consolegameswiki.com/wiki/Macros#Sound_Effects
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
public const uint Se1 = 1; // Soft chime
|
||||||
|
public const uint Se2 = 2; // Higher chime
|
||||||
|
public const uint Se3 = 3; // Bell tone
|
||||||
|
public const uint Se4 = 4; // Harp tone
|
||||||
|
public const uint Se5 = 5; // Mechanical click
|
||||||
|
public const uint Se6 = 6; // Drum / percussion
|
||||||
|
public const uint Se7 = 7; // Metallic chime
|
||||||
|
public const uint Se8 = 8; // Wooden tone
|
||||||
|
public const uint Se9 = 9; // Wind / flute tone
|
||||||
|
public const uint Se10 = 11; // Magical sparkle (ID 10 is skipped in game)
|
||||||
|
public const uint Se11 = 12; // Metallic ring
|
||||||
|
public const uint Se12 = 13; // Deep thud
|
||||||
|
public const uint Se13 = 14; // "Tell received" ping
|
||||||
|
public const uint Se14 = 15; // Success fanfare
|
||||||
|
public const uint Se15 = 16; // System warning
|
||||||
|
// Note: Se16 doesn't exist - Se15 is the last available sound
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// General notification sound (<se.2>)
|
||||||
|
/// </summary>
|
||||||
|
public const uint Info = Se2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Warning/alert sound (<se.15>)
|
||||||
|
/// </summary>
|
||||||
|
public const uint Warning = Se15;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Error sound (<se.15> - System warning, used for errors)
|
||||||
|
/// </summary>
|
||||||
|
public const uint Error = Se15;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Success sound (<se.14>)
|
||||||
|
/// </summary>
|
||||||
|
public const uint Success = Se14;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pair request sound (<se.13>, same as tell notification)
|
||||||
|
/// </summary>
|
||||||
|
public const uint PairRequest = Se13;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Download complete sound (<se.10>, a clean sparkle tone)
|
||||||
|
/// </summary>
|
||||||
|
public const uint DownloadComplete = Se10;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get default sound for notification type
|
||||||
|
/// </summary>
|
||||||
|
public static uint GetDefaultSound(NotificationType type) => type switch
|
||||||
|
{
|
||||||
|
NotificationType.Info => Info,
|
||||||
|
NotificationType.Warning => Warning,
|
||||||
|
NotificationType.Error => Error,
|
||||||
|
_ => Info
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
using Dalamud.Bindings.ImGui;
|
using Dalamud.Bindings.ImGui;
|
||||||
using Dalamud.Interface;
|
using Dalamud.Interface;
|
||||||
using Dalamud.Interface.Utility;
|
using Dalamud.Interface.Utility;
|
||||||
using Dalamud.Interface.Utility.Raii;
|
using Dalamud.Interface.Utility.Raii;
|
||||||
@@ -11,12 +11,8 @@ using LightlessSync.Services;
|
|||||||
using LightlessSync.Services.Mediator;
|
using LightlessSync.Services.Mediator;
|
||||||
using LightlessSync.Utils;
|
using LightlessSync.Utils;
|
||||||
using LightlessSync.WebAPI;
|
using LightlessSync.WebAPI;
|
||||||
using Serilog;
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using System.Reflection.Emit;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace LightlessSync.UI;
|
namespace LightlessSync.UI;
|
||||||
|
|
||||||
@@ -33,13 +29,14 @@ public class TopTabMenu
|
|||||||
private bool _pairRequestsExpanded; // useless for now
|
private bool _pairRequestsExpanded; // useless for now
|
||||||
private int _lastRequestCount;
|
private int _lastRequestCount;
|
||||||
private readonly UiSharedService _uiSharedService;
|
private readonly UiSharedService _uiSharedService;
|
||||||
|
private readonly NotificationService _lightlessNotificationService;
|
||||||
private string _filter = string.Empty;
|
private string _filter = string.Empty;
|
||||||
private int _globalControlCountdown = 0;
|
private int _globalControlCountdown = 0;
|
||||||
private float _pairRequestsHeight = 150f;
|
private float _pairRequestsHeight = 150f;
|
||||||
private string _pairToAdd = string.Empty;
|
private string _pairToAdd = string.Empty;
|
||||||
|
|
||||||
private SelectedTab _selectedTab = SelectedTab.None;
|
private SelectedTab _selectedTab = SelectedTab.None;
|
||||||
public TopTabMenu(LightlessMediator lightlessMediator, ApiController apiController, PairManager pairManager, UiSharedService uiSharedService, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService)
|
public TopTabMenu(LightlessMediator lightlessMediator, ApiController apiController, PairManager pairManager, UiSharedService uiSharedService, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService, NotificationService lightlessNotificationService)
|
||||||
{
|
{
|
||||||
_lightlessMediator = lightlessMediator;
|
_lightlessMediator = lightlessMediator;
|
||||||
_apiController = apiController;
|
_apiController = apiController;
|
||||||
@@ -47,6 +44,7 @@ public class TopTabMenu
|
|||||||
_pairRequestService = pairRequestService;
|
_pairRequestService = pairRequestService;
|
||||||
_dalamudUtilService = dalamudUtilService;
|
_dalamudUtilService = dalamudUtilService;
|
||||||
_uiSharedService = uiSharedService;
|
_uiSharedService = uiSharedService;
|
||||||
|
_lightlessNotificationService = lightlessNotificationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum SelectedTab
|
private enum SelectedTab
|
||||||
@@ -199,16 +197,79 @@ public class TopTabMenu
|
|||||||
if (TabSelection != SelectedTab.None) ImGuiHelpers.ScaledDummy(3f);
|
if (TabSelection != SelectedTab.None) ImGuiHelpers.ScaledDummy(3f);
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if (ImGui.Button("Add Test Pair Request"))
|
if (ImGui.Button("Test Pair Request"))
|
||||||
|
{
|
||||||
|
_lightlessNotificationService.ShowPairRequestNotification(
|
||||||
|
"Debug User",
|
||||||
|
"debug-user-id",
|
||||||
|
onAccept: () =>
|
||||||
|
{
|
||||||
|
_lightlessMediator.Publish(new NotificationMessage(
|
||||||
|
"Pair Accepted",
|
||||||
|
"Debug pair request was accepted!",
|
||||||
|
NotificationType.Info,
|
||||||
|
TimeSpan.FromSeconds(3)));
|
||||||
|
},
|
||||||
|
onDecline: () =>
|
||||||
|
{
|
||||||
|
_lightlessMediator.Publish(new NotificationMessage(
|
||||||
|
"Pair Declined",
|
||||||
|
"Debug pair request was declined.",
|
||||||
|
NotificationType.Warning,
|
||||||
|
TimeSpan.FromSeconds(3)));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui.SameLine();
|
||||||
|
if (ImGui.Button("Test Info"))
|
||||||
{
|
{
|
||||||
var fakeCid = Guid.NewGuid().ToString("N");
|
|
||||||
var display = _pairRequestService.RegisterIncomingRequest(fakeCid, "Debug pair request");
|
|
||||||
_lightlessMediator.Publish(new NotificationMessage(
|
_lightlessMediator.Publish(new NotificationMessage(
|
||||||
"Pair request received (debug)",
|
"Information",
|
||||||
display.Message,
|
"This is a test ifno notification with some longer text to see how it wraps. This is a test ifno notification with some longer text to see how it wraps. This is a test ifno notification with some longer text to see how it wraps. This is a test ifno notification with some longer text to see how it wraps.",
|
||||||
NotificationType.Info,
|
NotificationType.Info,
|
||||||
TimeSpan.FromSeconds(5)));
|
TimeSpan.FromSeconds(5)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ImGui.SameLine();
|
||||||
|
if (ImGui.Button("Test Warning"))
|
||||||
|
{
|
||||||
|
_lightlessMediator.Publish(new NotificationMessage(
|
||||||
|
"Warning",
|
||||||
|
"This is a test warning notification.",
|
||||||
|
NotificationType.Warning,
|
||||||
|
TimeSpan.FromSeconds(7)));
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui.SameLine();
|
||||||
|
if (ImGui.Button("Test Error"))
|
||||||
|
{
|
||||||
|
_lightlessMediator.Publish(new NotificationMessage(
|
||||||
|
"Error",
|
||||||
|
"This is a test error notification erp police",
|
||||||
|
NotificationType.Error,
|
||||||
|
TimeSpan.FromSeconds(10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ImGui.Button("Test Download Progress"))
|
||||||
|
{
|
||||||
|
var downloadStatus = new List<(string playerName, float progress, string status)>
|
||||||
|
{
|
||||||
|
("Mauwmauw Nekochan", 0.85f, "downloading"),
|
||||||
|
("Raelynn Kitsune", 0.34f, "downloading"),
|
||||||
|
("Jaina Elraeth", 0.67f, "downloading"),
|
||||||
|
("Vaelstra Bloodthorn", 0.19f, "downloading"),
|
||||||
|
("Lydia Hera Moondrop", 0.86f, "downloading"),
|
||||||
|
("C'liina Star", 1.0f, "completed")
|
||||||
|
};
|
||||||
|
|
||||||
|
_lightlessNotificationService.ShowPairDownloadNotification(downloadStatus);
|
||||||
|
}
|
||||||
|
ImGui.SameLine();
|
||||||
|
if (ImGui.Button("Dismiss Download"))
|
||||||
|
{
|
||||||
|
_lightlessNotificationService.DismissPairDownloadNotification();
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
DrawIncomingPairRequests(availableWidth);
|
DrawIncomingPairRequests(availableWidth);
|
||||||
@@ -850,4 +911,4 @@ public class TopTabMenu
|
|||||||
ImGui.EndPopup();
|
ImGui.EndPopup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using LightlessSync.API.Data;
|
using LightlessSync.API.Data;
|
||||||
using LightlessSync.API.Data.Enum;
|
using LightlessSync.API.Data.Enum;
|
||||||
using LightlessSync.API.Dto;
|
using LightlessSync.API.Dto;
|
||||||
using LightlessSync.API.Dto.CharaData;
|
using LightlessSync.API.Dto.CharaData;
|
||||||
@@ -6,6 +6,7 @@ using LightlessSync.API.Dto.Group;
|
|||||||
using LightlessSync.API.Dto.User;
|
using LightlessSync.API.Dto.User;
|
||||||
using LightlessSync.LightlessConfiguration.Models;
|
using LightlessSync.LightlessConfiguration.Models;
|
||||||
using LightlessSync.Services.Mediator;
|
using LightlessSync.Services.Mediator;
|
||||||
|
using LightlessSync.Utils;
|
||||||
using Microsoft.AspNetCore.SignalR.Client;
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -104,25 +105,27 @@ public partial class ApiController
|
|||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task Client_ReceiveBroadcastPairRequest(UserPairNotificationDto dto)
|
public Task Client_ReceiveBroadcastPairRequest(UserPairNotificationDto dto)
|
||||||
{
|
{
|
||||||
if (dto == null)
|
if (dto == null)
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|
||||||
var request = _pairRequestService.RegisterIncomingRequest(dto.myHashedCid, dto.message ?? string.Empty);
|
var request = _pairRequestService.RegisterIncomingRequest(dto.myHashedCid, dto.message ?? string.Empty);
|
||||||
|
var senderName = string.IsNullOrEmpty(request.DisplayName) ? "Unknown User" : request.DisplayName;
|
||||||
|
|
||||||
Mediator.Publish(new NotificationMessage(
|
_lightlessNotificationService.ShowPairRequestNotification(
|
||||||
"Pair request received",
|
senderName,
|
||||||
request.Message,
|
request.HashedCid,
|
||||||
NotificationType.Info,
|
onAccept: () => _pairRequestService.AcceptPairRequest(request.HashedCid),
|
||||||
TimeSpan.FromSeconds(5)));
|
onDecline: () => _pairRequestService.DeclinePairRequest(request.HashedCid));
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task Client_UpdateSystemInfo(SystemInfoDto systemInfo)
|
public Task Client_UpdateSystemInfo(SystemInfoDto systemInfo)
|
||||||
{
|
{
|
||||||
SystemInfoDto = systemInfo;
|
SystemInfoDto = systemInfo;
|
||||||
|
//Mediator.Publish(new UpdateSystemInfoMessage(systemInfo));
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Dalamud.Utility;
|
using Dalamud.Utility;
|
||||||
using LightlessSync.API.Data;
|
using LightlessSync.API.Data;
|
||||||
using LightlessSync.API.Data.Extensions;
|
using LightlessSync.API.Data.Extensions;
|
||||||
using LightlessSync.API.Dto;
|
using LightlessSync.API.Dto;
|
||||||
@@ -32,6 +32,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
|
|||||||
private readonly ServerConfigurationManager _serverManager;
|
private readonly ServerConfigurationManager _serverManager;
|
||||||
private readonly TokenProvider _tokenProvider;
|
private readonly TokenProvider _tokenProvider;
|
||||||
private readonly LightlessConfigService _lightlessConfigService;
|
private readonly LightlessConfigService _lightlessConfigService;
|
||||||
|
private readonly NotificationService _lightlessNotificationService;
|
||||||
private CancellationTokenSource _connectionCancellationTokenSource;
|
private CancellationTokenSource _connectionCancellationTokenSource;
|
||||||
private ConnectionDto? _connectionDto;
|
private ConnectionDto? _connectionDto;
|
||||||
private bool _doNotNotifyOnNextInfo = false;
|
private bool _doNotNotifyOnNextInfo = false;
|
||||||
@@ -44,7 +45,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
|
|||||||
|
|
||||||
public ApiController(ILogger<ApiController> logger, HubFactory hubFactory, DalamudUtilService dalamudUtil,
|
public ApiController(ILogger<ApiController> logger, HubFactory hubFactory, DalamudUtilService dalamudUtil,
|
||||||
PairManager pairManager, PairRequestService pairRequestService, ServerConfigurationManager serverManager, LightlessMediator mediator,
|
PairManager pairManager, PairRequestService pairRequestService, ServerConfigurationManager serverManager, LightlessMediator mediator,
|
||||||
TokenProvider tokenProvider, LightlessConfigService lightlessConfigService) : base(logger, mediator)
|
TokenProvider tokenProvider, LightlessConfigService lightlessConfigService, NotificationService lightlessNotificationService) : base(logger, mediator)
|
||||||
{
|
{
|
||||||
_hubFactory = hubFactory;
|
_hubFactory = hubFactory;
|
||||||
_dalamudUtil = dalamudUtil;
|
_dalamudUtil = dalamudUtil;
|
||||||
@@ -53,6 +54,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
|
|||||||
_serverManager = serverManager;
|
_serverManager = serverManager;
|
||||||
_tokenProvider = tokenProvider;
|
_tokenProvider = tokenProvider;
|
||||||
_lightlessConfigService = lightlessConfigService;
|
_lightlessConfigService = lightlessConfigService;
|
||||||
|
_lightlessNotificationService = lightlessNotificationService;
|
||||||
_connectionCancellationTokenSource = new CancellationTokenSource();
|
_connectionCancellationTokenSource = new CancellationTokenSource();
|
||||||
|
|
||||||
Mediator.Subscribe<DalamudLoginMessage>(this, (_) => DalamudUtilOnLogIn());
|
Mediator.Subscribe<DalamudLoginMessage>(this, (_) => DalamudUtilOnLogIn());
|
||||||
|
|||||||
Reference in New Issue
Block a user