Files
LightlessClient/LightlessSync/UI/SyncshellAdminUI.cs
defnotken 72a62b7449
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m9s
2.1.0 (#123)
# Patchnotes 2.1.0
The changes in this update are more than just "patches". With a new UI, a new feature, and a bunch of bug fixes, improvements and a new member on the dev team, we thought this was more of a minor update.

We would like to introduce @tsubasahane of MareCN to the team! We’re happy to work with them to bring Lightless and its features to the CN client as well as having another talented dev bring features and ideas to us. Speaking of which:

# Location Sharing (Big shout out to @tsubasahane for bringing this feature)

- Are you TIRED of scrambling to find the address of the venue you're in to share with your friends? We are introducing Location Sharing! An optional feature where you can share your location with direct pairs temporarily [30 minutes, 1 hour, 3 hours] minutes or until you turn it off for them. That's up to you! [#125](<#125>)  [#49](<Lightless-Sync/LightlessServer#49>)
- To share your location with a pair, click the three dots beside the pair and choose a duration to share with them. [#125](<#125>)  [#49](<Lightless-Sync/LightlessServer#49>)
- To view the location of someone who's shared with you, simply hover over the globe icon! [#125](<#125>)  [#49](<Lightless-Sync/LightlessServer#49>)

[1]

# Model Optimization (Mesh Decimating)
 - This new option can automatically “simplify” incoming character meshes to help performance by reducing triangle counts. You choose how strong the reduction is (default/recommended is 80%). [#131](<#131>)
 - Decimation only kicks in when a mesh is above a certain triangle threshold, and only for the items that qualify for it and you selected for. [#131](<#131>)
 - Hair meshes is always excluded, since simplifying hair meshes is very prone to breaking.
 - You can find everything under Settings → Performance → Model Optimization. [#131](<#131>)
+ ** IF YOU HAVE USED DECIMATION IN TESTING, PLEASE CLEAR YOUR CACHE  **

[2]

# Animation (PAP) Validation (Safer animations)
 - Lightless now checks your currently animations to see if they work with your local skeleton/bone mod. If an animation matches, it’s included in what gets sent to other players. If it doesn’t, Lightless will skip it and write a warning to your log showing how many were skipped due to skeleton changes. Its defaulted to Unsafe (off). turn it on if you experience crashes from others users. [#131](<#131>)
 - Lightless also does the same kind of check for incoming animation files, to make sure they match the body/skeleton they were sent with. [#131](<#131>)
 - Because these checks can sometimes be a little picky, you can adjust how strict they are in Settings -> General -> Animation & Bones to reduce false positives. [#131](<#131>)

# UI Changes (Thanks to @kyuwu for UI Changes)
- The top part of the main screen has gotten a makeover. You can adjust the colors of the gradiant in the Color settings of Lightless. [#127](<#127>)

[3]

- Settings have gotten some changes as well to make this change more universal, and will use the same color settings. [#127](<#127>)
- The particle effects of the gradient are toggleable in 'Settings -> UI -> Behavior' [#127](<#127>)
- Instead of showing download/upload on bottom of Main UI, it will show VRAM usage and triangles with their optimization options next to it [#138](<#138>)

# LightFinder / ShellFinder
- UI Changes that follow our new design follow the color codes for the Gradient top as the main screen does.  [#127](<#127>)

[4]

Co-authored-by: defnotken <itsdefnotken@gmail.com>
Co-authored-by: azyges <aaaaaa@aaa.aaa>
Co-authored-by: cake <admin@cakeandbanana.nl>
Co-authored-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
Co-authored-by: choco <choco@patat.nl>
Co-authored-by: celine <aaa@aaa.aaa>
Co-authored-by: celine <celine@noreply.git.lightless-sync.org>
Co-authored-by: Tsubasahane <wozaiha@gmail.com>
Co-authored-by: cake <cake@noreply.git.lightless-sync.org>
Reviewed-on: #123
2026-01-20 19:43:00 +00:00

1453 lines
55 KiB
C#

using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
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;
using LightlessSync.Services.Profiles;
using LightlessSync.UI.Services;
using LightlessSync.WebAPI;
using Microsoft.Extensions.Logging;
using SharpDX.DirectWrite;
using SixLabors.ImageSharp;
using System.Globalization;
using System.Numerics;
namespace LightlessSync.UI;
public class SyncshellAdminUI : WindowMediatorSubscriberBase
{
private readonly ApiController _apiController;
private readonly bool _isModerator = false;
private readonly bool _isOwner = false;
private readonly List<string> _oneTimeInvites = [];
private readonly LightlessProfileManager _lightlessProfileManager;
private readonly UiSharedService _uiSharedService;
private readonly PairUiService _pairUiService;
private List<BannedGroupUserDto> _bannedUsers = [];
private LightlessGroupProfileData? _profileData = null;
private string _userSearchFilter = string.Empty;
private IDalamudTextureWrap? _pfpTextureWrap;
private string _profileDescription = string.Empty;
private int _multiInvites;
private string _newPassword;
private bool _pwChangeSuccess;
private Task<int>? _pruneTestTask;
private Task<int>? _pruneTask;
private int _pruneDays = 14;
// Ban management fields
private Task<List<BannedGroupUserDto>>? _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<string, string> _banReasonEdits = new(StringComparer.Ordinal);
private Task? _banEditTask;
private string? _banEditError;
private Task<GroupPruneSettingsDto>? _pruneSettingsTask;
private bool _pruneSettingsLoaded;
private bool _autoPruneEnabled;
private int _autoPruneDays = 14;
private readonly PairFactory _pairFactory;
public SyncshellAdminUI(ILogger<SyncshellAdminUI> logger, LightlessMediator mediator, ApiController apiController,
UiSharedService uiSharedService, PairUiService pairUiService, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, LightlessProfileManager lightlessProfileManager, PairFactory pairFactory)
: base(logger, mediator, "Syncshell Admin Panel (" + groupFullInfo.GroupAliasOrGID + ")", performanceCollectorService)
{
GroupFullInfo = groupFullInfo;
_apiController = apiController;
_uiSharedService = uiSharedService;
_lightlessProfileManager = lightlessProfileManager;
_pairUiService = pairUiService;
_isOwner = string.Equals(GroupFullInfo.OwnerUID, _apiController.UID, StringComparison.Ordinal);
_isModerator = GroupFullInfo.GroupUserInfo.IsModerator();
_newPassword = string.Empty;
_multiInvites = 30;
_pwChangeSuccess = true;
IsOpen = true;
Mediator.Subscribe<ClearProfileGroupDataMessage>(this, (msg) =>
{
if (msg.GroupData == null || string.Equals(msg.GroupData.AliasOrGID, GroupFullInfo.Group.AliasOrGID, StringComparison.Ordinal))
{
_pfpTextureWrap?.Dispose();
_pfpTextureWrap = null;
}
});
SizeConstraints = new WindowSizeConstraints()
{
MinimumSize = new(700, 500),
MaximumSize = new(700, 2000),
};
_pairUiService = pairUiService;
_pairFactory = pairFactory;
}
public GroupFullInfoDto GroupFullInfo { get; private set; }
protected override void DrawInternal()
{
if (!_isModerator && !_isOwner) return;
_logger.LogTrace("Drawing Syncshell Admin UI for {group}", GroupFullInfo.GroupAliasOrGID);
var snapshot = _pairUiService.GetSnapshot();
if (snapshot.GroupsByGid.TryGetValue(GroupFullInfo.Group.GID, out var updatedInfo))
{
GroupFullInfo = updatedInfo;
}
_profileData = _lightlessProfileManager.GetLightlessGroupProfile(GroupFullInfo.Group);
using var id = ImRaii.PushId("syncshell_admin_" + GroupFullInfo.GID);
DrawAdminHeader();
ImGui.Separator();
var perm = GroupFullInfo.GroupPermissions;
DrawAdminTopBar(perm);
}
private void DrawAdminHeader()
{
float scale = ImGuiHelpers.GlobalScale;
var style = ImGui.GetStyle();
var cursorLocal = ImGui.GetCursorPos();
var pMin = ImGui.GetCursorScreenPos();
float width = ImGui.GetContentRegionAvail().X;
float height = 64f * scale;
var pMax = new Vector2(pMin.X + width, pMin.Y + height);
var drawList = ImGui.GetWindowDrawList();
var purple = UIColors.Get("LightlessPurple");
var gradLeft = purple.WithAlpha(0.0f);
var gradRight = purple.WithAlpha(0.85f);
uint colTopLeft = ImGui.ColorConvertFloat4ToU32(gradLeft);
uint colTopRight = ImGui.ColorConvertFloat4ToU32(gradRight);
uint colBottomRight = ImGui.ColorConvertFloat4ToU32(gradRight);
uint colBottomLeft = ImGui.ColorConvertFloat4ToU32(gradLeft);
drawList.AddRectFilledMultiColor(
pMin,
pMax,
colTopLeft,
colTopRight,
colBottomRight,
colBottomLeft);
float accentHeight = 3f * scale;
var accentMin = new Vector2(pMin.X, pMax.Y - accentHeight);
var accentMax = new Vector2(pMax.X, pMax.Y);
var accentColor = UIColors.Get("LightlessBlue");
uint accentU32 = ImGui.ColorConvertFloat4ToU32(accentColor);
drawList.AddRectFilled(accentMin, accentMax, accentU32);
ImGui.InvisibleButton("##adminHeaderHitbox", new Vector2(width, height));
if (ImGui.IsItemHovered())
{
ImGui.BeginTooltip();
ImGui.Text($"{GroupFullInfo.GroupAliasOrGID} is created at:");
ImGui.Separator();
ImGui.Text(GroupFullInfo.Group.CreatedAt?.ToString("yyyy-MM-dd HH:mm:ss 'UTC'") ?? "Unknown");
ImGui.EndTooltip();
}
var titlePos = new Vector2(pMin.X + 12f * scale, pMin.Y + 8f * scale);
ImGui.SetCursorScreenPos(titlePos);
float titleHeight;
using (_uiSharedService.UidFont.Push())
{
ImGui.TextColored(UIColors.Get("LightlessBlue"), GroupFullInfo.GroupAliasOrGID);
titleHeight = ImGui.GetTextLineHeightWithSpacing();
}
var subtitlePos = new Vector2(
pMin.X + 12f * scale,
titlePos.Y + titleHeight - 2f * scale);
ImGui.SetCursorScreenPos(subtitlePos);
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey);
ImGui.TextUnformatted("Administrative Panel");
ImGui.PopStyleColor();
string roleLabel = _isOwner ? "Owner" : (_isModerator ? "Moderator" : string.Empty);
if (!string.IsNullOrEmpty(roleLabel))
{
float roleTextW = ImGui.CalcTextSize(roleLabel).X;
float pillPaddingX = 8f * scale;
float pillPaddingY = -1f * scale;
float pillWidth = roleTextW + pillPaddingX * 2f;
float pillHeight = ImGui.GetTextLineHeight() + pillPaddingY * 2f;
var pillMin = new Vector2(
pMax.X - pillWidth - style.WindowPadding.X,
subtitlePos.Y - pillPaddingY);
var pillMax = new Vector2(pillMin.X + pillWidth, pillMin.Y + pillHeight);
var pillBg = _isOwner ? UIColors.Get("LightlessYellow") : UIColors.Get("LightlessOrange");
uint pillBgU = ImGui.ColorConvertFloat4ToU32(pillBg.WithAlpha(0.9f));
drawList.AddRectFilled(pillMin, pillMax, pillBgU, 8f * scale);
ImGui.SetCursorScreenPos(new Vector2(pillMin.X + pillPaddingX, pillMin.Y + pillPaddingY));
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("FullBlack"));
ImGui.TextUnformatted(roleLabel);
ImGui.PopStyleColor();
}
ImGui.SetCursorPos(new Vector2(cursorLocal.X, cursorLocal.Y + height + 6f * scale));
}
private void DrawAdminTopBar(GroupPermissions perm)
{
var style = ImGui.GetStyle();
ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(12f, 6f) * ImGuiHelpers.GlobalScale);
ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(10f, style.ItemSpacing.Y));
ImGui.PushStyleVar(ImGuiStyleVar.TabRounding, 6f * ImGuiHelpers.GlobalScale);
var baseTab = UIColors.Get("FullBlack").WithAlpha(0.0f);
var baseTabDim = UIColors.Get("FullBlack").WithAlpha(0.1f);
var accent = UIColors.Get("LightlessPurple");
var accentHover = accent.WithAlpha(0.90f);
var accentActive = accent;
using (ImRaii.PushColor(ImGuiCol.Tab, baseTab))
using (ImRaii.PushColor(ImGuiCol.TabHovered, accentHover))
using (ImRaii.PushColor(ImGuiCol.TabActive, accentActive))
using (ImRaii.PushColor(ImGuiCol.TabUnfocused, baseTabDim))
using (ImRaii.PushColor(ImGuiCol.TabUnfocusedActive, accentActive.WithAlpha(0.80f)))
{
using (var tabbar = ImRaii.TabBar("syncshell_tab_" + GroupFullInfo.GID))
{
if (tabbar)
{
DrawInvites(perm);
DrawManagement();
DrawPermission(perm);
DrawProfile();
}
}
}
ImGui.PopStyleVar(3);
ImGuiHelpers.ScaledDummy(2f);
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f);
ImGuiHelpers.ScaledDummy(2f);
}
private void DrawPermission(GroupPermissions perm)
{
var permissionTab = ImRaii.TabItem("Permissions");
if (permissionTab)
{
bool isDisableAnimations = perm.IsPreferDisableAnimations();
bool isDisableSounds = perm.IsPreferDisableSounds();
bool isDisableVfx = perm.IsPreferDisableVFX();
ImGui.AlignTextToFramePadding();
ImGui.Text("Suggest Sound Sync");
_uiSharedService.BooleanToColoredIcon(!isDisableSounds);
ImGui.SameLine(230);
using (ImRaii.PushColor(ImGuiCol.Text, isDisableSounds ? UIColors.Get("PairBlue") : UIColors.Get("DimRed")))
{
if (_uiSharedService.IconTextButton(isDisableSounds ? FontAwesomeIcon.VolumeUp : FontAwesomeIcon.VolumeMute,
isDisableSounds ? "Suggest to enable sound sync" : "Suggest to disable sound sync"))
{
perm.SetPreferDisableSounds(!perm.IsPreferDisableSounds());
_ = _apiController.GroupChangeGroupPermissionState(new(GroupFullInfo.Group, perm));
}
}
ImGui.AlignTextToFramePadding();
ImGui.Text("Suggest Animation Sync");
_uiSharedService.BooleanToColoredIcon(!isDisableAnimations);
ImGui.SameLine(230);
using (ImRaii.PushColor(ImGuiCol.Text, isDisableAnimations ? UIColors.Get("PairBlue") : UIColors.Get("DimRed")))
{
if (_uiSharedService.IconTextButton(isDisableAnimations ? FontAwesomeIcon.Running : FontAwesomeIcon.Stop,
isDisableAnimations ? "Suggest to enable animation sync" : "Suggest to disable animation sync"))
{
perm.SetPreferDisableAnimations(!perm.IsPreferDisableAnimations());
_ = _apiController.GroupChangeGroupPermissionState(new(GroupFullInfo.Group, perm));
}
}
ImGui.AlignTextToFramePadding();
ImGui.Text("Suggest VFX Sync");
_uiSharedService.BooleanToColoredIcon(!isDisableVfx);
ImGui.SameLine(230);
using (ImRaii.PushColor(ImGuiCol.Text, isDisableVfx ? UIColors.Get("PairBlue") : UIColors.Get("DimRed")))
{
if (_uiSharedService.IconTextButton(isDisableVfx ? FontAwesomeIcon.Sun : FontAwesomeIcon.Circle,
isDisableVfx ? "Suggest to enable vfx sync" : "Suggest to disable vfx sync"))
{
perm.SetPreferDisableVFX(!perm.IsPreferDisableVFX());
_ = _apiController.GroupChangeGroupPermissionState(new(GroupFullInfo.Group, perm));
}
}
UiSharedService.TextWrapped("Note: those suggested permissions will be shown to users on joining the Syncshell.");
}
permissionTab.Dispose();
if (_isOwner)
{
var ownerTab = ImRaii.TabItem("Owner Settings");
if (ownerTab)
{
bool isChatDisabled = perm.IsDisableChat();
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Syncshell Chat");
_uiSharedService.BooleanToColoredIcon(!isChatDisabled);
ImGui.SameLine(230);
using (ImRaii.PushColor(ImGuiCol.Text, isChatDisabled ? UIColors.Get("PairBlue") : UIColors.Get("DimRed")))
{
if (_uiSharedService.IconTextButton(
isChatDisabled ? FontAwesomeIcon.Comment : FontAwesomeIcon.Ban,
isChatDisabled ? "Enable syncshell chat" : "Disable syncshell chat"))
{
perm.SetDisableChat(!isChatDisabled);
_ = _apiController.GroupChangeGroupPermissionState(new(GroupFullInfo.Group, perm));
}
}
UiSharedService.AttachToolTip("Disables syncshell chat for all members.");
ImGuiHelpers.ScaledDummy(6f);
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("New Password");
var availableWidth = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X;
var buttonSize = _uiSharedService.GetIconTextButtonSize(FontAwesomeIcon.Passport, "Change Password");
var textSize = ImGui.CalcTextSize("New Password").X;
var spacing = ImGui.GetStyle().ItemSpacing.X;
ImGui.SameLine();
ImGui.SetNextItemWidth(availableWidth - buttonSize - textSize - spacing * 2);
ImGui.InputTextWithHint("##changepw", "Min 10 characters", ref _newPassword, 50);
ImGui.SameLine();
using (ImRaii.Disabled(_newPassword.Length < 10))
{
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Passport, "Change Password"))
{
_pwChangeSuccess = _apiController.GroupChangePassword(new GroupPasswordDto(GroupFullInfo.Group, _newPassword)).Result;
_newPassword = string.Empty;
}
}
UiSharedService.AttachToolTip("Password requires to be at least 10 characters long. This action is irreversible.");
if (!_pwChangeSuccess)
{
UiSharedService.ColorTextWrapped("Failed to change the password. Password requires to be at least 10 characters long.", ImGuiColors.DalamudYellow);
}
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Delete Syncshell") && UiSharedService.CtrlPressed() && UiSharedService.ShiftPressed())
{
IsOpen = false;
_ = _apiController.GroupDelete(new(GroupFullInfo.Group));
}
UiSharedService.AttachToolTip("Hold CTRL and Shift and click to delete this Syncshell." + Environment.NewLine + "WARNING: this action is irreversible.");
}
ownerTab.Dispose();
}
}
private void DrawAutoPruneSettings()
{
ImGuiHelpers.ScaledDummy(2f);
UiSharedService.TextWrapped("Automatic prune (server-side scheduled cleanup of inactive users).");
_pruneSettingsTask ??= _apiController.GroupGetPruneSettings(new GroupDto(GroupFullInfo.Group));
if (!_pruneSettingsLoaded)
{
if (!_pruneSettingsTask!.IsCompleted)
{
UiSharedService.ColorTextWrapped("Loading prune settings from server...", ImGuiColors.DalamudGrey);
return;
}
if (_pruneSettingsTask.IsFaulted || _pruneSettingsTask.IsCanceled)
{
UiSharedService.ColorTextWrapped("Failed to load auto-prune settings.", ImGuiColors.DalamudRed);
_pruneSettingsTask = null;
_pruneSettingsLoaded = false;
return;
}
var dto = _pruneSettingsTask.GetAwaiter().GetResult();
_autoPruneEnabled = dto.AutoPruneEnabled && dto.AutoPruneDays > 0;
_autoPruneDays = dto.AutoPruneDays > 0 ? dto.AutoPruneDays : 14;
_pruneSettingsLoaded = true;
}
bool enabled = _autoPruneEnabled;
if (ImGui.Checkbox("Enable automatic pruning", ref enabled))
{
_autoPruneEnabled = enabled;
SavePruneSettings();
}
UiSharedService.AttachToolTip("When enabled, inactive non-pinned, non-moderator users will be pruned automatically on the server.");
if (!_autoPruneEnabled)
{
ImGui.BeginDisabled();
}
ImGui.SameLine();
ImGui.SetNextItemWidth(150);
_uiSharedService.DrawCombo(
"Day(s) of inactivity (gets checked hourly)",
[0, 1, 3, 7, 14, 30, 90],
(count) => count == 0 ? "2 hours(s)" : count + " day(s)",
selected =>
{
_autoPruneDays = selected;
SavePruneSettings();
},
_autoPruneDays);
if (!_autoPruneEnabled)
{
ImGui.EndDisabled();
UiSharedService.ColorTextWrapped(
"Automatic prune is currently disabled. Enable it and choose an inactivity threshold to let the server clean up inactive users automatically.",
ImGuiColors.DalamudGrey);
}
}
private void DrawProfile()
{
var profileTab = ImRaii.TabItem("Profile");
if (!profileTab)
return;
if (_profileData != null)
{
if (!string.Equals(_profileDescription, _profileData.Description, StringComparison.Ordinal))
{
_profileDescription = _profileData.Description;
}
UiSharedService.TextWrapped("Preview the Syncshell profile in a standalone window.");
if (_uiSharedService.IconTextButton(FontAwesomeIcon.AddressCard, "Open Syncshell Profile"))
{
Mediator.Publish(new GroupProfileOpenStandaloneMessage(GroupFullInfo.Group));
}
UiSharedService.AttachToolTip("Opens the standalone Syncshell profile window for this group.");
ImGuiHelpers.ScaledDummy(2f);
ImGui.TextDisabled("Profile Flags");
ImGui.BulletText(_profileData.IsNsfw ? "Marked as NSFW" : "Marked as SFW");
ImGui.BulletText(_profileData.IsDisabled ? "Profile disabled for viewers" : "Profile active");
ImGuiHelpers.ScaledDummy(2f);
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f);
ImGuiHelpers.ScaledDummy(2f);
UiSharedService.TextWrapped("Open the syncshell profile editor to update images, description, tags, and visibility settings.");
ImGuiHelpers.ScaledDummy(2f);
if (_uiSharedService.IconTextButton(FontAwesomeIcon.UserEdit, "Open Syncshell Profile Editor"))
{
Mediator.Publish(new OpenGroupProfileEditorMessage(GroupFullInfo));
}
UiSharedService.AttachToolTip("Launches the editor window and associated live preview for this syncshell.");
}
else
{
UiSharedService.TextWrapped("Profile information is loading...");
}
profileTab.Dispose();
}
private void DrawManagement()
{
var mgmtTab = ImRaii.TabItem("User Management");
if (!mgmtTab)
return;
ImGuiHelpers.ScaledDummy(3f);
var style = ImGui.GetStyle();
var baseTab = UIColors.Get("FullBlack").WithAlpha(0.0f);
var baseTabDim = UIColors.Get("FullBlack").WithAlpha(0.1f);
var accent = UIColors.Get("LightlessPurple");
var accentHover = accent.WithAlpha(0.90f);
var accentActive = accent;
//Pushing style vars for inner tab bar
ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(10f, 5f) * ImGuiHelpers.GlobalScale);
ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(8f, style.ItemSpacing.Y));
ImGui.PushStyleVar(ImGuiStyleVar.TabRounding, 5f * ImGuiHelpers.GlobalScale);
try
{
//Pushing color stack for inner tab bar
using (ImRaii.PushColor(ImGuiCol.Tab, baseTab))
using (ImRaii.PushColor(ImGuiCol.TabHovered, accentHover))
using (ImRaii.PushColor(ImGuiCol.TabActive, accentActive))
using (ImRaii.PushColor(ImGuiCol.TabUnfocused, baseTabDim))
using (ImRaii.PushColor(ImGuiCol.TabUnfocusedActive, accentActive.WithAlpha(0.80f)))
{
using (var innerTabBar = ImRaii.TabBar("syncshell_tab_" + GroupFullInfo.GID))
{
if (innerTabBar)
{
// Users tab
var usersTab = ImRaii.TabItem("Users");
if (usersTab)
{
DrawUserListSection();
}
usersTab.Dispose();
// Cleanup tab
var cleanupTab = ImRaii.TabItem("Cleanup");
if (cleanupTab)
{
DrawMassCleanupSection();
}
cleanupTab.Dispose();
// Bans tab
var bansTab = ImRaii.TabItem("Bans");
if (bansTab)
{
DrawUserBansSection();
}
bansTab.Dispose();
}
}
}
mgmtTab.Dispose();
}
finally
{
// Popping style vars (3) for inner tab bar
ImGui.PopStyleVar(3);
}
}
private void DrawUserListSection()
{
var snapshot = _pairUiService.GetSnapshot();
if (!snapshot.GroupPairs.TryGetValue(GroupFullInfo, out var pairs))
{
UiSharedService.ColorTextWrapped("No users found in this Syncshell", ImGuiColors.DalamudYellow);
return;
}
_uiSharedService.MediumText("User List & Administration", UIColors.Get("LightlessPurple"));
ImGuiHelpers.ScaledDummy(2f);
DrawUserListCustom(pairs, GroupFullInfo);
}
private void DrawMassCleanupSection()
{
_uiSharedService.MediumText("Mass Cleanup", UIColors.Get("DimRed"));
UiSharedService.TextWrapped("Tools to bulk-clean inactive or unwanted users from this Syncshell.");
ImGuiHelpers.ScaledDummy(3f);
using (ImRaii.Disabled(!UiSharedService.CtrlPressed()))
{
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Broom, "Clear Syncshell"))
{
_ = _apiController.GroupClear(new(GroupFullInfo.Group));
}
}
UiSharedService.AttachToolTip("This will remove all non-pinned, non-moderator users from the Syncshell."
+ UiSharedService.TooltipSeparator + "Hold CTRL to enable this button");
ImGui.SameLine();
using (ImRaii.Disabled(!UiSharedService.CtrlPressed()))
{
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Brush, "Clear Lightfinder Users"))
{
_ = _apiController.GroupClearFinder(new(GroupFullInfo.Group));
}
}
UiSharedService.AttachToolTip("This will remove all users that joined through Lightfinder from the Syncshell."
+ UiSharedService.TooltipSeparator + "Hold CTRL to enable this button");
ImGuiHelpers.ScaledDummy(2f);
UiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 1.0f);
ImGuiHelpers.ScaledDummy(2f);
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Unlink, "Check for Inactive Users"))
{
_pruneTestTask = _apiController.GroupPrune(new(GroupFullInfo.Group), _pruneDays, execute: false);
_pruneTask = null;
}
UiSharedService.AttachToolTip($"This will start the prune process for this Syncshell of inactive Lightless users that have not logged in in the past {_pruneDays} day(s)."
+ Environment.NewLine + "You will be able to review the amount of inactive users before executing the prune."
+ UiSharedService.TooltipSeparator + "Note: this check excludes pinned users and moderators of this Syncshell.");
ImGui.SameLine();
ImGui.SetNextItemWidth(150);
_uiSharedService.DrawCombo(
"Day(s) of inactivity",
[0, 1, 3, 7, 14, 30, 90],
(count) => count == 0 ? "2 hours(s)" : count + " day(s)",
(selected) =>
{
_pruneDays = selected;
_pruneTestTask = null;
_pruneTask = null;
},
_pruneDays);
if (_pruneTestTask != null)
{
if (!_pruneTestTask.IsCompleted)
{
UiSharedService.ColorTextWrapped("Calculating inactive users...", ImGuiColors.DalamudYellow);
}
else
{
ImGui.AlignTextToFramePadding();
UiSharedService.TextWrapped($"Found {_pruneTestTask.Result} user(s) that have not logged into Lightless in the past {_pruneDays} day(s).");
if (_pruneTestTask.Result > 0)
{
using (ImRaii.Disabled(!UiSharedService.CtrlPressed()))
{
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Broom, "Prune Inactive Users"))
{
_pruneTask = _apiController.GroupPrune(new(GroupFullInfo.Group), _pruneDays, execute: true);
_pruneTestTask = null;
}
}
UiSharedService.AttachToolTip($"Pruning will remove {_pruneTestTask?.Result ?? 0} inactive user(s)."
+ UiSharedService.TooltipSeparator + "Hold CTRL to enable this button");
}
}
}
if (_pruneTask != null)
{
if (!_pruneTask.IsCompleted)
{
UiSharedService.ColorTextWrapped("Pruning Syncshell...", ImGuiColors.DalamudYellow);
}
else
{
UiSharedService.TextWrapped($"Syncshell was pruned and {_pruneTask.Result} inactive user(s) have been removed.");
}
}
ImGuiHelpers.ScaledDummy(4f);
UiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 1.0f);
ImGuiHelpers.ScaledDummy(2f);
DrawAutoPruneSettings();
}
private void DrawUserBansSection()
{
_uiSharedService.MediumText("User Bans", UIColors.Get("LightlessYellow"));
ImGuiHelpers.ScaledDummy(3f);
EnsureBanListLoaded();
DrawNewBanEntryRow();
ImGuiHelpers.ScaledDummy(4f);
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Retweet, "Refresh Banlist"))
{
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 colMeta = fullW * 0.35f;
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())
{
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 (Not Alias!) 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).";
}
QueueBanListRefresh(force: true);
_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;
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;
await Task.Delay(450).ConfigureAwait(false);
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");
if (inviteTab)
{
bool isInvitesDisabled = perm.IsDisableInvites();
if (_uiSharedService.IconTextButton(isInvitesDisabled ? FontAwesomeIcon.Unlock : FontAwesomeIcon.Lock,
isInvitesDisabled ? "Unlock Syncshell" : "Lock Syncshell"))
{
perm.SetDisableInvites(!isInvitesDisabled);
_ = _apiController.GroupChangeGroupPermissionState(new(GroupFullInfo.Group, perm));
}
ImGuiHelpers.ScaledDummy(2f);
UiSharedService.TextWrapped("One-time invites work as single-use passwords. Use those if you do not want to distribute your Syncshell password.");
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Envelope, "Single one-time invite"))
{
ImGui.SetClipboardText(_apiController.GroupCreateTempInvite(new(GroupFullInfo.Group), 1).Result.FirstOrDefault() ?? string.Empty);
}
UiSharedService.AttachToolTip("Creates a single-use password for joining the syncshell which is valid for 24h and copies it to the clipboard.");
ImGui.InputInt("##amountofinvites", ref _multiInvites);
ImGui.SameLine();
using (ImRaii.Disabled(_multiInvites <= 1 || _multiInvites > 100))
{
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Envelope, "Generate " + _multiInvites + " one-time invites"))
{
_oneTimeInvites.AddRange(_apiController.GroupCreateTempInvite(new(GroupFullInfo.Group), _multiInvites).Result);
}
}
if (_oneTimeInvites.Any())
{
var invites = string.Join(Environment.NewLine, _oneTimeInvites);
ImGui.InputTextMultiline("Generated Multi Invites", ref invites, 5000, new(0, 0), ImGuiInputTextFlags.ReadOnly);
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Copy, "Copy Invites to clipboard"))
{
ImGui.SetClipboardText(invites);
}
}
}
inviteTab.Dispose();
}
private void DrawUserListCustom(IReadOnlyList<Pair> pairs, GroupFullInfoDto GroupFullInfo)
{
// Search bar
ImGui.PushItemWidth(0);
_uiSharedService.IconText(FontAwesomeIcon.Search, UIColors.Get("LightlessPurple"));
ImGui.SameLine();
ImGui.InputTextWithHint(
"##UserSearchFilter",
"Search UID/alias or note...",
ref _userSearchFilter,
64);
ImGui.PopItemWidth();
ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale));
var groupedPairs = new Dictionary<Pair, GroupPairUserInfo?>(
pairs.Select(p => new KeyValuePair<Pair, GroupPairUserInfo?>(
p,
GroupFullInfo.GroupPairUserInfos.TryGetValue(p.UserData.UID, out var value) ? value : null
))
);
var filter = _userSearchFilter?.Trim();
bool hasFilter = !string.IsNullOrEmpty(filter);
if (hasFilter)
filter = filter!.ToLowerInvariant();
var orderedPairs = groupedPairs
.Where(p => !hasFilter || MatchesUserFilter(p.Key, filter!))
.OrderBy(p =>
{
if (p.Value == null) return 10;
if (string.Equals(p.Key.UserData.UID, GroupFullInfo.OwnerUID, StringComparison.Ordinal)) return 0;
if (p.Value.Value.IsModerator()) return 1;
if (p.Value.Value.IsPinned()) return 2;
return 10;
})
.ThenBy(p => p.Key.GetNote() ?? p.Key.UserData.AliasOrUID, StringComparer.OrdinalIgnoreCase)
.ToList();
DrawUserListHeader();
ImGui.BeginChild("userListScroll#" + GroupFullInfo.Group.AliasOrGID, new Vector2(0, 0), true);
int rowIndex = 0;
foreach (var kv in orderedPairs)
{
var pair = kv.Key;
var userInfoOpt = kv.Value;
DrawUserRowCustom(pair, userInfoOpt, GroupFullInfo, rowIndex++);
}
ImGui.EndChild();
}
private static void DrawUserListHeader()
{
var style = ImGui.GetStyle();
float x0 = ImGui.GetCursorPosX();
float fullW = ImGui.GetContentRegionAvail().X;
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessPurple"));
ImGui.SetCursorPosX(x0);
ImGui.TextUnformatted("User");
const string actionsLabel = "Actions";
float labelWidth = ImGui.CalcTextSize(actionsLabel).X;
ImGui.SameLine();
ImGui.SetCursorPosX(x0 + fullW - labelWidth);
ImGui.TextUnformatted(actionsLabel);
ImGui.PopStyleColor();
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.0f);
}
private void DrawUserRowCustom(Pair pair, GroupPairUserInfo? userInfoOpt, GroupFullInfoDto GroupFullInfo, int rowIndex)
{
using var id = ImRaii.PushId("userRow_" + pair.UserData.UID);
var style = ImGui.GetStyle();
Vector2 rowStart = ImGui.GetCursorPos();
Vector2 rowStartScr = ImGui.GetCursorScreenPos();
float fullW = ImGui.GetContentRegionAvail().X;
float frameH = ImGui.GetFrameHeight();
float textH = ImGui.GetTextLineHeight();
float rowHeight = frameH;
if (rowIndex % 2 == 0)
{
var drawList = ImGui.GetWindowDrawList();
var pMin = rowStartScr;
var pMax = new Vector2(pMin.X + fullW, pMin.Y + rowHeight);
var bgColor = UIColors.Get("FullBlack").WithAlpha(0.05f);
drawList.AddRectFilled(pMin, pMax, ImGui.ColorConvertFloat4ToU32(bgColor));
}
var isUserOwner = string.Equals(pair.UserData.UID, GroupFullInfo.OwnerUID, StringComparison.Ordinal);
var userInfo = userInfoOpt ?? GroupPairUserInfo.None;
float baselineY = rowStart.Y + (rowHeight - textH) / 2f;
ImGui.SetCursorPos(new Vector2(rowStart.X, baselineY));
bool hasFlag = false;
if (userInfoOpt != null && (userInfo.IsModerator() || userInfo.IsPinned() || isUserOwner))
{
if (userInfo.IsModerator())
{
_uiSharedService.IconText(FontAwesomeIcon.UserShield, UIColors.Get("LightlessPurple"));
UiSharedService.AttachToolTip("Moderator");
hasFlag = true;
}
if (userInfo.IsPinned() && !isUserOwner)
{
if (hasFlag) ImGui.SameLine(0f, style.ItemSpacing.X);
_uiSharedService.IconText(FontAwesomeIcon.Thumbtack);
UiSharedService.AttachToolTip("Pinned");
hasFlag = true;
}
if (isUserOwner)
{
if (hasFlag) ImGui.SameLine(0f, style.ItemSpacing.X);
_uiSharedService.IconText(FontAwesomeIcon.Crown, UIColors.Get("LightlessYellow"));
UiSharedService.AttachToolTip("Owner");
hasFlag = true;
}
}
if (hasFlag)
ImGui.SameLine(0f, style.ItemSpacing.X);
ImGui.SetCursorPosY(baselineY);
var note = pair.GetNote();
var text = note == null
? pair.UserData.AliasOrUID
: $"{note} ({pair.UserData.AliasOrUID})";
var boolcolor = UiSharedService.GetBoolColor(pair.IsOnline);
UiSharedService.ColorText(text, boolcolor);
if (ImGui.IsItemClicked())
ImGui.SetClipboardText(pair.UserData.AliasOrUID);
if (!string.IsNullOrEmpty(pair.PlayerName))
UiSharedService.AttachToolTip(pair.PlayerName + $"{Environment.NewLine}Click to copy UID or Alias");
DrawUserActions(pair, GroupFullInfo, userInfo, isUserOwner, baselineY);
// Move cursor to next row
ImGui.SetCursorPos(new Vector2(rowStart.X, rowStart.Y + rowHeight + style.ItemSpacing.Y));
}
private void DrawUserActions(Pair pair, GroupFullInfoDto GroupFullInfo, GroupPairUserInfo userInfo, bool isUserOwner, float baselineY)
{
var style = ImGui.GetStyle();
float frameH = ImGui.GetFrameHeight();
int buttonCount = 0;
if (_isOwner)
buttonCount += 2; // Crown + Mod
if (userInfo == GroupPairUserInfo.None || (!userInfo.IsModerator() && !isUserOwner))
buttonCount += 3; // Pin + Trash + Ban
if (buttonCount == 0)
return;
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;
float startX = curX + MathF.Max(0, avail - (totalWidth + 30f));
ImGui.SetCursorPos(new Vector2(startX, baselineY));
bool first = true;
if (_isOwner)
{
// Transfer ownership
using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("LightlessYellow")))
using (ImRaii.Disabled(!UiSharedService.ShiftPressed()))
{
if (_uiSharedService.IconButton(FontAwesomeIcon.Crown))
{
_ = _apiController.GroupChangeOwnership(new(GroupFullInfo.Group, pair.UserData));
IsOpen = false;
}
}
UiSharedService.AttachToolTip("Hold SHIFT and click to transfer ownership of this Syncshell to "
+ pair.UserData.AliasOrUID + Environment.NewLine
+ "WARNING: This action is irreversible and will close screen.");
first = false;
// Mod / Demod
using (ImRaii.PushColor(ImGuiCol.Text,
userInfo.IsModerator() ? UIColors.Get("DimRed") : UIColors.Get("PairBlue")))
{
if (!first) ImGui.SameLine(0f, style.ItemSpacing.X);
if (_uiSharedService.IconButton(FontAwesomeIcon.UserShield))
{
userInfo.SetModerator(!userInfo.IsModerator());
_ = _apiController.GroupSetUserInfo(
new GroupPairUserInfoDto(GroupFullInfo.Group, pair.UserData, userInfo));
}
}
UiSharedService.AttachToolTip(
userInfo.IsModerator() ? $"Demod {pair.UserData.AliasOrUID}" : $"Mod {pair.UserData.AliasOrUID}");
first = false;
}
if (userInfo == GroupPairUserInfo.None || (!userInfo.IsModerator() && !isUserOwner))
{
// Pin
using (ImRaii.PushColor(ImGuiCol.Text,
userInfo.IsPinned() ? UIColors.Get("DimRed") : UIColors.Get("PairBlue")))
{
if (!first) ImGui.SameLine(0f, style.ItemSpacing.X);
if (_uiSharedService.IconButton(FontAwesomeIcon.Thumbtack))
{
userInfo.SetPinned(!userInfo.IsPinned());
_ = _apiController.GroupSetUserInfo(
new GroupPairUserInfoDto(GroupFullInfo.Group, pair.UserData, userInfo));
}
}
UiSharedService.AttachToolTip(
userInfo.IsPinned() ? $"Unpin {pair.UserData.AliasOrUID}" : $"Pin {pair.UserData.AliasOrUID}");
first = false;
// Trash
using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("DimRed")))
using (ImRaii.Disabled(!UiSharedService.CtrlPressed()))
{
if (!first) ImGui.SameLine(0f, style.ItemSpacing.X);
if (_uiSharedService.IconButton(FontAwesomeIcon.Trash))
{
_ = _apiController.GroupRemoveUser(new GroupPairDto(GroupFullInfo.Group, pair.UserData));
}
}
UiSharedService.AttachToolTip($"Remove {pair.UserData.AliasOrUID} from Syncshell"
+ UiSharedService.TooltipSeparator + "Hold CTRL to enable this button");
first = false;
// Ban
using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("DimRed")))
using (ImRaii.Disabled(!UiSharedService.CtrlPressed()))
{
if (!first) ImGui.SameLine(0f, style.ItemSpacing.X);
if (_uiSharedService.IconButton(FontAwesomeIcon.Ban))
{
Mediator.Publish(new OpenBanUserPopupMessage(pair, GroupFullInfo));
}
}
UiSharedService.AttachToolTip($"Ban {pair.UserData.AliasOrUID} from Syncshell"
+ UiSharedService.TooltipSeparator + "Hold CTRL to enable this button");
}
}
private static void DrawBannedListHeader(float colIdentity, float colMeta)
{
var style = ImGui.GetStyle();
float x0 = ImGui.GetCursorPosX();
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessYellow"));
// User and reason
ImGui.SetCursorPosX(x0);
ImGui.TextUnformatted("User / Reason");
// Moderator and Date
ImGui.SameLine();
ImGui.SetCursorPosX(x0 + colIdentity + style.ItemSpacing.X);
ImGui.TextUnformatted("Moderator / Date");
// Actions
ImGui.SameLine();
ImGui.SetCursorPosX(x0 + colIdentity + colMeta + style.ItemSpacing.X * 2.0f);
ImGui.TextUnformatted("Actions");
ImGui.PopStyleColor();
UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.0f);
}
private void QueueBanListRefresh(bool force = false)
{
if (!force)
{
if (_bannedUsersTask != null && !_bannedUsersTask.IsCompleted)
return;
}
_bannedUsersLoaded = false;
_bannedUsersLoadError = null;
_bannedUsersTask = _apiController.GroupGetBannedUsers(new GroupDto(GroupFullInfo.Group));
}
private void EnsureBanListLoaded()
{
_bannedUsersTask ??= _apiController.GroupGetBannedUsers(new GroupDto(GroupFullInfo.Group));
if (_bannedUsersLoaded || _bannedUsersTask == null)
return;
if (!_bannedUsersTask.IsCompleted)
return;
if (_bannedUsersTask.IsFaulted || _bannedUsersTask.IsCanceled)
{
_bannedUsersLoadError = "Failed to load banlist from server.";
_bannedUsers = [];
_bannedUsersLoaded = true;
return;
}
_bannedUsers = _bannedUsersTask.GetAwaiter().GetResult() ?? [];
_bannedUsersLoaded = true;
}
private void SavePruneSettings()
{
if (_autoPruneDays <= 0)
{
_autoPruneEnabled = false;
}
var dto = new GroupPruneSettingsDto(Group: GroupFullInfo.Group, AutoPruneEnabled: _autoPruneEnabled, AutoPruneDays: _autoPruneDays);
try
{
_apiController.GroupSetPruneSettings(dto).GetAwaiter().GetResult();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save auto prune settings for group {GID}", GroupFullInfo.Group.GID);
UiSharedService.ColorTextWrapped("Failed to save auto-prune settings.", ImGuiColors.DalamudRed);
}
}
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;
var uid = pair.UserData.UID ?? string.Empty;
var alias = pair.UserData.AliasOrUID ?? string.Empty;
return note.Contains(filterLower, StringComparison.OrdinalIgnoreCase)
|| uid.Contains(filterLower, StringComparison.OrdinalIgnoreCase)
|| alias.Contains(filterLower, StringComparison.OrdinalIgnoreCase);
}
public override void OnOpen()
{
base.OnOpen();
QueueBanListRefresh(force: true);
}
public override void OnClose()
{
Mediator.Publish(new RemoveWindowMessage(this));
_pfpTextureWrap?.Dispose();
}
}