diff --git a/LightlessSync/Services/UiFactory.cs b/LightlessSync/Services/UiFactory.cs index 33ab3ae..cbc64f4 100644 --- a/LightlessSync/Services/UiFactory.cs +++ b/LightlessSync/Services/UiFactory.cs @@ -8,6 +8,7 @@ using LightlessSync.UI.Tags; using LightlessSync.WebAPI; using LightlessSync.UI.Services; using Microsoft.Extensions.Logging; +using LightlessSync.PlayerData.Factories; namespace LightlessSync.Services; @@ -23,6 +24,7 @@ public class UiFactory private readonly PerformanceCollectorService _performanceCollectorService; private readonly ProfileTagService _profileTagService; private readonly DalamudUtilService _dalamudUtilService; + private readonly PairFactory _pairFactory; public UiFactory( ILoggerFactory loggerFactory, @@ -34,7 +36,8 @@ public class UiFactory LightlessProfileManager lightlessProfileManager, PerformanceCollectorService performanceCollectorService, ProfileTagService profileTagService, - DalamudUtilService dalamudUtilService) + DalamudUtilService dalamudUtilService, + PairFactory pairFactory) { _loggerFactory = loggerFactory; _lightlessMediator = lightlessMediator; @@ -46,6 +49,7 @@ public class UiFactory _performanceCollectorService = performanceCollectorService; _profileTagService = profileTagService; _dalamudUtilService = dalamudUtilService; + _pairFactory = pairFactory; } public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto) @@ -58,7 +62,8 @@ public class UiFactory _pairUiService, dto, _performanceCollectorService, - _lightlessProfileManager); + _lightlessProfileManager, + _pairFactory); } public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair) diff --git a/LightlessSync/UI/SyncshellAdminUI.cs b/LightlessSync/UI/SyncshellAdminUI.cs index 526b5ae..eee8ab9 100644 --- a/LightlessSync/UI/SyncshellAdminUI.cs +++ b/LightlessSync/UI/SyncshellAdminUI.cs @@ -4,9 +4,11 @@ using Dalamud.Interface.Colors; using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; +using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.Group; +using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.Mediator; @@ -42,13 +44,32 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase private Task? _pruneTask; private int _pruneDays = 14; + // Ban management fields + private Task>? _bannedUsersTask; + private bool _bannedUsersLoaded; + private string? _bannedUsersLoadError; + + private string _newBanUid = string.Empty; + private string _newBanReason = string.Empty; + private Task? _newBanTask; + private string? _newBanError; + private DateTime _newBanBusyUntilUtc; + + // Ban editing fields + private string? _editingBanUid; + private readonly Dictionary _banReasonEdits = new(StringComparer.Ordinal); + + private Task? _banEditTask; + private string? _banEditError; + private Task? _pruneSettingsTask; private bool _pruneSettingsLoaded; private bool _autoPruneEnabled; private int _autoPruneDays = 14; + private readonly PairFactory _pairFactory; public SyncshellAdminUI(ILogger logger, LightlessMediator mediator, ApiController apiController, - UiSharedService uiSharedService, PairUiService pairUiService, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, LightlessProfileManager lightlessProfileManager) + UiSharedService uiSharedService, PairUiService pairUiService, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, LightlessProfileManager lightlessProfileManager, PairFactory pairFactory) : base(logger, mediator, "Syncshell Admin Panel (" + groupFullInfo.GroupAliasOrGID + ")", performanceCollectorService) { GroupFullInfo = groupFullInfo; @@ -76,6 +97,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase MaximumSize = new(700, 2000), }; _pairUiService = pairUiService; + _pairFactory = pairFactory; } public GroupFullInfoDto GroupFullInfo { get; private set; } @@ -654,34 +676,343 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase _uiSharedService.MediumText("User Bans", UIColors.Get("LightlessYellow")); ImGuiHelpers.ScaledDummy(3f); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Retweet, "Refresh Banlist from Server")) + EnsureBanListLoaded(); + + DrawNewBanEntryRow(); + + ImGuiHelpers.ScaledDummy(4f); + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Retweet, "Refresh Banlist")) { - _bannedUsers = _apiController.GroupGetBannedUsers(new GroupDto(GroupFullInfo.Group)).Result; + QueueBanListRefresh(force: true); } + ImGuiHelpers.ScaledDummy(2f); + if (!_bannedUsersLoaded) + { + UiSharedService.ColorTextWrapped("Loading banlist from server...", ImGuiColors.DalamudGrey); + return; + } + + if (!string.IsNullOrWhiteSpace(_bannedUsersLoadError)) + { + UiSharedService.ColorTextWrapped(_bannedUsersLoadError!, ImGuiColors.DalamudRed); + return; + } + ImGui.BeginChild("bannedListScroll#" + GroupFullInfo.GID, new Vector2(0, 0), true); var style = ImGui.GetStyle(); float fullW = ImGui.GetContentRegionAvail().X; + float scale = ImGuiHelpers.GlobalScale; + + float frame = ImGui.GetFrameHeight(); + float actionIcons = 3; + float colActions = actionIcons * frame + (actionIcons - 1) * style.ItemSpacing.X + 10f * scale; - float colIdentity = fullW * 0.45f; float colMeta = fullW * 0.35f; - float colActions = fullW - colIdentity - colMeta - style.ItemSpacing.X * 2.0f; - // Header + float colIdentity = fullW - colMeta - colActions - style.ItemSpacing.X * 2.0f; + + float minIdentity = fullW * 0.40f; + if (colIdentity < minIdentity) + { + colIdentity = minIdentity; + colMeta = fullW - colIdentity - colActions - style.ItemSpacing.X * 2.0f; + if (colMeta < 80f * scale) colMeta = 80f * scale; + } + DrawBannedListHeader(colIdentity, colMeta); int rowIndex = 0; foreach (var bannedUser in _bannedUsers.ToList()) { - // Each row DrawBannedRow(bannedUser, rowIndex++, colIdentity, colMeta, colActions); } ImGui.EndChild(); } + private void DrawNewBanEntryRow() + { + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessYellow")); + ImGui.TextUnformatted("Add new ban"); + ImGui.PopStyleColor(); + + UiSharedService.TextWrapped("Enter a UID and optional reason. (Hold CTRL to enable the ban button.)"); + + var style = ImGui.GetStyle(); + float fullW = ImGui.GetContentRegionAvail().X; + + float uidW = fullW * 0.35f; + float reasonW = fullW * 0.50f; + float btnW = fullW - uidW - reasonW - style.ItemSpacing.X * 2f; + + // UID + ImGui.SetNextItemWidth(uidW); + ImGui.InputTextWithHint("##newBanUid", "UID...", ref _newBanUid, 128); + + // Reason + ImGui.SameLine(0f, style.ItemSpacing.X); + ImGui.SetNextItemWidth(reasonW); + ImGui.InputTextWithHint("##newBanReason", "Reason (optional)...", ref _newBanReason, 256); + + // Ban button + ImGui.SameLine(0f, style.ItemSpacing.X); + + var trimmedUid = (_newBanUid ?? string.Empty).Trim(); + var now = DateTime.UtcNow; + bool taskRunning = _newBanTask != null && !_newBanTask.IsCompleted; + bool busyLatched = now < _newBanBusyUntilUtc; + bool busy = taskRunning || busyLatched; + + bool canBan = UiSharedService.CtrlPressed() + && !string.IsNullOrWhiteSpace(_newBanUid) + && !busy; + + using (ImRaii.Disabled(!canBan)) + using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("DimRed"))) + { + ImGui.SetNextItemWidth(btnW); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.UserSlash, "Ban")) + { + _newBanError = null; + + _newBanBusyUntilUtc = DateTime.UtcNow.AddMilliseconds(750); + + _newBanTask = SubmitNewBanByUidAsync(trimmedUid, _newBanReason); + } + } + UiSharedService.AttachToolTip("Hold CTRL to enable banning by UID."); + + if (busy) + { + UiSharedService.ColorTextWrapped("Banning user...", ImGuiColors.DalamudGrey); + } + + if (_newBanTask != null && _newBanTask.IsCompleted && DateTime.UtcNow >= _newBanBusyUntilUtc) + { + if (_newBanTask.IsFaulted) + { + var _ = _newBanTask.Exception; + _newBanError ??= "Ban failed (see log)."; + } + + _newBanTask = null; + } + } + + private async Task SubmitNewBanByUidAsync(string uidOrAlias, string reason) + { + try + { + await Task.Yield(); + + uidOrAlias = (uidOrAlias ?? string.Empty).Trim(); + reason = (reason ?? string.Empty).Trim(); + + if (string.IsNullOrWhiteSpace(uidOrAlias)) + { + _newBanError = "UID is empty."; + return; + } + + string targetUid = uidOrAlias; + string? typedAlias = null; + + // Try to resolve alias to UID if applicable + var snap = _pairUiService.GetSnapshot(); + if (snap.GroupPairs.TryGetValue(GroupFullInfo, out var pairs)) + { + var match = pairs.FirstOrDefault(p => + string.Equals(p.UserData.UID, uidOrAlias, StringComparison.Ordinal) || + string.Equals(p.UserData.AliasOrUID, uidOrAlias, StringComparison.OrdinalIgnoreCase)); + + if (match != null) + { + targetUid = match.UserData.UID; + typedAlias = match.UserData.Alias; + } + else + { + typedAlias = null; + } + } + + var userData = new UserData(UID: targetUid, Alias: typedAlias); + + await _apiController + .GroupBanUser(new GroupPairDto(GroupFullInfo.Group, userData), reason) + .ConfigureAwait(false); + + _newBanUid = string.Empty; + _newBanReason = string.Empty; + _newBanError = null; + + QueueBanListRefresh(force: true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to ban '{uidOrAlias}' in group {gid}", uidOrAlias, GroupFullInfo.Group.GID); + _newBanError = "Failed to ban user (see log)."; + } + } + + private async Task SaveBanReasonViaBanUserAsync(string uid) + { + try + { + if (!_banReasonEdits.TryGetValue(uid, out var newReason)) + newReason = string.Empty; + + newReason = (newReason ?? string.Empty).Trim(); + + var userData = new UserData(uid.Trim()); + + await _apiController + .GroupBanUser(new GroupPairDto(GroupFullInfo.Group, userData), newReason) + .ConfigureAwait(false); + + _editingBanUid = null; + _banEditError = null; + + QueueBanListRefresh(force: true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to edit ban reason for {uid} in group {gid}", uid, GroupFullInfo.Group.GID); + _banEditError = "Failed to update reason (see log)."; + } + } + + private void DrawBannedRow(BannedGroupUserDto bannedUser, int rowIndex, float colIdentity, float colMeta, float colActions) + { + using var id = ImRaii.PushId("banRow_" + bannedUser.UID); + + var style = ImGui.GetStyle(); + float x0 = ImGui.GetCursorPosX(); + + if (rowIndex % 2 == 0) + { + var drawList = ImGui.GetWindowDrawList(); + var pMin = ImGui.GetCursorScreenPos(); + var rowHeight = ImGui.GetTextLineHeightWithSpacing() * 2.6f; + var pMax = new Vector2( + pMin.X + colIdentity + colMeta + colActions + style.ItemSpacing.X * 2.0f, + pMin.Y + rowHeight); + + var bgColor = UIColors.Get("FullBlack").WithAlpha(0.10f); + drawList.AddRectFilled(pMin, pMax, ImGui.ColorConvertFloat4ToU32(bgColor)); + } + + ImGui.SetCursorPosX(x0); + ImGui.AlignTextToFramePadding(); + + string alias = bannedUser.UserAlias ?? string.Empty; + string line1 = string.IsNullOrEmpty(alias) + ? bannedUser.UID + : $"{alias} ({bannedUser.UID})"; + + ImGui.TextUnformatted(line1); + + var fullReason = bannedUser.Reason ?? string.Empty; + + if (string.Equals(_editingBanUid, bannedUser.UID, StringComparison.Ordinal)) + { + _banReasonEdits.TryGetValue(bannedUser.UID, out var editReason); + editReason ??= StripAliasSuffix(fullReason); + + ImGui.SetCursorPosX(x0); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); + ImGui.SetNextItemWidth(colIdentity); + ImGui.InputTextWithHint("##banReasonEdit", "Reason...", ref editReason, 255); + ImGui.PopStyleColor(); + + _banReasonEdits[bannedUser.UID] = editReason; + + if (!string.IsNullOrWhiteSpace(_banEditError)) + UiSharedService.ColorTextWrapped(_banEditError!, ImGuiColors.DalamudRed); + } + else + { + if (!string.IsNullOrWhiteSpace(fullReason)) + { + ImGui.SetCursorPosX(x0); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); + + ImGui.PushTextWrapPos(x0 + colIdentity); + UiSharedService.TextWrapped(fullReason); + ImGui.PopTextWrapPos(); + + ImGui.PopStyleColor(); + } + } + + ImGui.SameLine(); + ImGui.SetCursorPosX(x0 + colIdentity + style.ItemSpacing.X); + + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted($"By: {bannedUser.BannedBy}"); + + var dateText = bannedUser.BannedOn.ToLocalTime().ToString(CultureInfo.CurrentCulture); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); + ImGui.TextUnformatted(dateText); + ImGui.PopStyleColor(); + ImGui.SameLine(); + + float frame = ImGui.GetFrameHeight(); + float actionsX0 = x0 + colIdentity + colMeta + style.ItemSpacing.X * 2.0f; + + ImGui.SameLine(); + ImGui.SetCursorPosX(actionsX0); + + bool isEditing = string.Equals(_editingBanUid, bannedUser.UID, StringComparison.Ordinal); + int actionCount = 1 + (isEditing ? 2 : 1); + + float totalW = actionCount * frame + (actionCount - 1) * style.ItemSpacing.X; + float startX = actionsX0 + MathF.Max(0, colActions - totalW) - 36f; + ImGui.SetCursorPosX(startX); + + if (_uiSharedService.IconButton(FontAwesomeIcon.Check)) + { + _apiController.GroupUnbanUser(bannedUser); + _bannedUsers.RemoveAll(b => string.Equals(b.UID, bannedUser.UID, StringComparison.Ordinal)); + } + UiSharedService.AttachToolTip("Unban"); + + ImGui.SameLine(0f, style.ItemSpacing.X); + + if (!isEditing) + { + if (_uiSharedService.IconButton(FontAwesomeIcon.Edit)) + { + _banEditError = null; + _editingBanUid = bannedUser.UID; + _banReasonEdits[bannedUser.UID] = StripAliasSuffix(bannedUser.Reason ?? string.Empty); + } + UiSharedService.AttachToolTip("Edit reason"); + } + else + { + if (_uiSharedService.IconButton(FontAwesomeIcon.Save)) + { + _banEditError = null; + _banEditTask = SaveBanReasonViaBanUserAsync(bannedUser.UID); + } + UiSharedService.AttachToolTip("Save"); + + ImGui.SameLine(0f, style.ItemSpacing.X); + + if (_uiSharedService.IconButton(FontAwesomeIcon.Times)) + { + _banEditError = null; + _editingBanUid = null; + } + UiSharedService.AttachToolTip("Cancel"); + } + } + private void DrawInvites(GroupPermissions perm) { var inviteTab = ImRaii.TabItem("Invites"); @@ -902,7 +1233,9 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase if (buttonCount == 0) return; - float totalWidth = buttonCount * frameH + (buttonCount - 1) * style.ItemSpacing.X; + float totalWidth = _isOwner + ? buttonCount * frameH + buttonCount * style.ItemSpacing.X + 20f + : buttonCount * frameH + buttonCount * style.ItemSpacing.X; float curX = ImGui.GetCursorPosX(); float avail = ImGui.GetContentRegionAvail().X; @@ -1031,69 +1364,40 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.0f); } - private void DrawBannedRow(BannedGroupUserDto bannedUser, int rowIndex, float colIdentity, float colMeta, float colActions) + private void QueueBanListRefresh(bool force = false) { - using var id = ImRaii.PushId("banRow_" + bannedUser.UID); - - var style = ImGui.GetStyle(); - float x0 = ImGui.GetCursorPosX(); - - if (rowIndex % 2 == 0) + if (!force) { - var drawList = ImGui.GetWindowDrawList(); - var pMin = ImGui.GetCursorScreenPos(); - var rowHeight = ImGui.GetTextLineHeightWithSpacing() * 2.6f; - var pMax = new Vector2( - pMin.X + colIdentity + colMeta + colActions + style.ItemSpacing.X * 2.0f, - pMin.Y + rowHeight); - - var bgColor = UIColors.Get("FullBlack").WithAlpha(0.10f); - drawList.AddRectFilled(pMin, pMax, ImGui.ColorConvertFloat4ToU32(bgColor)); + if (_bannedUsersTask != null && !_bannedUsersTask.IsCompleted) + return; } - ImGui.SetCursorPosX(x0); - ImGui.AlignTextToFramePadding(); + _bannedUsersLoaded = false; + _bannedUsersLoadError = null; - string alias = bannedUser.UserAlias ?? string.Empty; - string line1 = string.IsNullOrEmpty(alias) - ? bannedUser.UID - : $"{alias} ({bannedUser.UID})"; + _bannedUsersTask = _apiController.GroupGetBannedUsers(new GroupDto(GroupFullInfo.Group)); + } - ImGui.TextUnformatted(line1); + private void EnsureBanListLoaded() + { + _bannedUsersTask ??= _apiController.GroupGetBannedUsers(new GroupDto(GroupFullInfo.Group)); - var reason = bannedUser.Reason ?? string.Empty; - if (!string.IsNullOrWhiteSpace(reason)) + if (_bannedUsersLoaded || _bannedUsersTask == null) + return; + + if (!_bannedUsersTask.IsCompleted) + return; + + if (_bannedUsersTask.IsFaulted || _bannedUsersTask.IsCanceled) { - var reasonPos = new Vector2(x0, ImGui.GetCursorPosY()); - ImGui.SetCursorPos(reasonPos); - ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); - UiSharedService.TextWrapped(reason); - ImGui.PopStyleColor(); + _bannedUsersLoadError = "Failed to load banlist from server."; + _bannedUsers = []; + _bannedUsersLoaded = true; + return; } - ImGui.SameLine(); - ImGui.SetCursorPosX(x0 + colIdentity + style.ItemSpacing.X); - - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted($"By: {bannedUser.BannedBy}"); - - var dateText = bannedUser.BannedOn.ToLocalTime().ToString(CultureInfo.CurrentCulture); - ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); - ImGui.TextUnformatted(dateText); - ImGui.PopStyleColor(); - - ImGui.SameLine(); - ImGui.SetCursorPosX(x0 + colIdentity + colMeta + style.ItemSpacing.X * 2.0f); - - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Check, "Unban")) - { - _apiController.GroupUnbanUser(bannedUser); - _bannedUsers.RemoveAll(b => string.Equals(b.UID, bannedUser.UID, StringComparison.Ordinal)); - } - - UiSharedService.AttachToolTip($"Unban {alias} ({bannedUser.UID}) from this Syncshell"); - - ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); + _bannedUsers = _bannedUsersTask.GetAwaiter().GetResult() ?? []; + _bannedUsersLoaded = true; } private void SavePruneSettings() @@ -1116,6 +1420,13 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase } } + private static string StripAliasSuffix(string reason) + { + const string marker = " (Alias at time of ban:"; + var idx = reason.IndexOf(marker, StringComparison.Ordinal); + return idx >= 0 ? reason[..idx] : reason; + } + private static bool MatchesUserFilter(Pair pair, string filterLower) { var note = pair.GetNote() ?? string.Empty; @@ -1127,6 +1438,11 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase || alias.Contains(filterLower, StringComparison.OrdinalIgnoreCase); } + public override void OnOpen() + { + base.OnOpen(); + QueueBanListRefresh(force: true); + } public override void OnClose() { Mediator.Publish(new RemoveWindowMessage(this));