using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.Components; using Dalamud.Interface.ImGuiFileDialog; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using LightlessSync.API.Data; using LightlessSync.API.Dto.Group; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Services.Profiles; using LightlessSync.UI.Tags; using LightlessSync.Utils; using Microsoft.Extensions.Logging; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.PixelFormats; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Numerics; using System.Threading.Tasks; namespace LightlessSync.UI; public partial class EditProfileUi { private void OpenGroupEditor(GroupFullInfoDto groupInfo) { _mode = ProfileEditorMode.Group; _groupInfo = groupInfo; var profile = _lightlessProfileManager.GetLightlessGroupProfile(groupInfo.Group); _groupProfileData = profile; SyncGroupProfileState(profile, resetSelection: true); var scale = ImGuiHelpers.GlobalScale; var viewport = ImGui.GetMainViewport(); ProfileEditorLayoutCoordinator.Enable(groupInfo.Group.GID); ProfileEditorLayoutCoordinator.EnsureAnchor(viewport.WorkPos, scale); Mediator.Publish(new GroupProfileOpenStandaloneMessage(groupInfo)); IsOpen = true; _wasOpen = true; } private void ResetGroupEditorState() { _groupInfo = null; _groupProfileData = null; _groupIsNsfw = false; _groupIsDisabled = false; _groupServerIsNsfw = false; _groupServerIsDisabled = false; _queuedProfileImage = null; _queuedBannerImage = null; _profileImage = Array.Empty(); _bannerImage = Array.Empty(); _profileDescription = string.Empty; _descriptionText = string.Empty; _profileTagIds = Array.Empty(); _tagEditorSelection.Clear(); _pfpTextureWrap?.Dispose(); _pfpTextureWrap = null; _bannerTextureWrap?.Dispose(); _bannerTextureWrap = null; _showProfileImageError = false; _showBannerImageError = false; } private void DrawGroupEditor(float scale) { if (_groupInfo is null) { UiSharedService.TextWrapped("Open the Syncshell admin panel and choose a group to edit its profile."); return; } var viewport = ImGui.GetMainViewport(); var linked = ProfileEditorLayoutCoordinator.IsActive(_groupInfo.Group.GID); if (linked) { ProfileEditorLayoutCoordinator.EnsureAnchor(viewport.WorkPos, scale); var desiredSize = ProfileEditorLayoutCoordinator.GetEditorSize(scale); if (!ProfileEditorLayoutCoordinator.NearlyEquals(ImGui.GetWindowSize(), desiredSize)) ImGui.SetWindowSize(desiredSize, ImGuiCond.Always); var currentPos = ImGui.GetWindowPos(); if (IsWindowBeingDragged()) ProfileEditorLayoutCoordinator.UpdateAnchorFromEditor(currentPos, scale); var desiredPos = ProfileEditorLayoutCoordinator.GetEditorPosition(scale); if (!ProfileEditorLayoutCoordinator.NearlyEquals(currentPos, desiredPos)) ImGui.SetWindowPos(desiredPos, ImGuiCond.Always); } else { var defaultProfilePos = viewport.WorkPos + new Vector2(50f, 70f) * scale; var defaultEditorPos = defaultProfilePos + ProfileEditorLayoutCoordinator.GetEditorOffset(scale); ImGui.SetWindowPos(defaultEditorPos, ImGuiCond.FirstUseEver); } if (_queuedProfileImage is not null) ApplyQueuedGroupProfileImage(); if (_queuedBannerImage is not null) ApplyQueuedGroupBannerImage(); var profile = _lightlessProfileManager.GetLightlessGroupProfile(_groupInfo.Group); _groupProfileData = profile; SyncGroupProfileState(profile, resetSelection: false); var accent = UIColors.Get("LightlessPurple"); var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.015f); var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.07f); using var panelBg = ImRaii.PushColor(ImGuiCol.ChildBg, accentBg); using var panelBorder = ImRaii.PushColor(ImGuiCol.ChildBg, accentBorder); ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale); if (ImGui.BeginChild("##GroupProfileEditorCanvas", -Vector2.One, true, ImGuiWindowFlags.NoScrollbar)) { DrawGroupGuidelinesSection(scale); ImGui.Dummy(new Vector2(0f, 4f * scale)); DrawGroupProfileContent(profile, scale); } ImGui.EndChild(); ImGui.PopStyleVar(); } private void DrawGroupGuidelinesSection(float scale) { DrawSection("Guidelines", scale, () => { ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(1f, 1f)); _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessBlue"), "All users that are paired and unpaused with you will be able to see your profile pictures, tags and description."); _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), "Other users have the possibility to report this profile for breaking the rules."); _uiSharedService.DrawNoteLine("!!! ", UIColors.Get("DimRed"), "AVOID: Anything as profile image that can be considered highly illegal or obscene (bestiality, anything that could be considered a sexual act with a minor (that includes Lalafells), etc.)"); _uiSharedService.DrawNoteLine("!!! ", UIColors.Get("DimRed"), "AVOID: Slurs of any kind in the description that can be considered highly offensive"); _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), "In case of valid reports from other users this can lead to disabling the profile forever or terminating syncshell owner's Lightless account indefinitely."); _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), "Judgement of the profile validity from reports through staff is not up to debate and the decisions to disable the profile or your account permanent."); _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessBlue"), "If the profile picture or profile description could be considered NSFW, enable the toggle in visibility settings."); ImGui.PopStyleVar(); }); } private void DrawGroupProfileContent(LightlessGroupProfileData profile, float scale) { DrawSection("Profile Preview", scale, () => DrawGroupProfileSnapshot(profile, scale)); DrawSection("Profile Image", scale, DrawGroupProfileImageControls); DrawSection("Profile Banner", scale, DrawGroupProfileBannerControls); DrawSection("Profile Description", scale, DrawGroupProfileDescriptionEditor); DrawSection("Profile Tags", scale, () => DrawGroupProfileTagsEditor(scale)); DrawSection("Visibility", scale, DrawGroupProfileVisibilityControls); } private void DrawGroupProfileSnapshot(LightlessGroupProfileData profile, float scale) { var bannerHeight = 140f * scale; ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale); if (ImGui.BeginChild("##GroupProfileBannerPreview", new Vector2(-1f, bannerHeight), true)) { if (_bannerTextureWrap != null) { var childSize = ImGui.GetWindowSize(); var padding = ImGui.GetStyle().WindowPadding; var contentSize = new Vector2( MathF.Max(childSize.X - padding.X * 2f, 1f), MathF.Max(childSize.Y - padding.Y * 2f, 1f)); var imageSize = ImGuiHelpers.ScaledVector2(_bannerTextureWrap.Width, _bannerTextureWrap.Height); if (imageSize.X > contentSize.X || imageSize.Y > contentSize.Y) { var ratio = MathF.Min(contentSize.X / MathF.Max(imageSize.X, 1f), contentSize.Y / MathF.Max(imageSize.Y, 1f)); imageSize *= ratio; } var offset = new Vector2( MathF.Max((contentSize.X - imageSize.X) * 0.5f, 0f), MathF.Max((contentSize.Y - imageSize.Y) * 0.5f, 0f)); ImGui.SetCursorPos(padding + offset); ImGui.Image(_bannerTextureWrap.Handle, imageSize); } else { ImGui.TextColored(UIColors.Get("LightlessPurple"), "No profile banner uploaded."); } } ImGui.EndChild(); ImGui.PopStyleVar(); ImGui.Dummy(new Vector2(0f, 6f * scale)); if (_pfpTextureWrap != null) { var size = ImGuiHelpers.ScaledVector2(_pfpTextureWrap.Width, _pfpTextureWrap.Height); var maxEdge = 160f * scale; if (size.X > maxEdge || size.Y > maxEdge) { var ratio = MathF.Min(maxEdge / MathF.Max(size.X, 1f), maxEdge / MathF.Max(size.Y, 1f)); size *= ratio; } ImGui.Image(_pfpTextureWrap.Handle, size); } else { ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale); if (ImGui.BeginChild("##GroupProfileImagePlaceholder", new Vector2(160f * scale, 160f * scale), true)) ImGui.TextColored(UIColors.Get("LightlessPurple"), "No profile picture uploaded."); ImGui.EndChild(); ImGui.PopStyleVar(); } ImGui.SameLine(); ImGui.BeginGroup(); ImGui.TextColored(UIColors.Get("LightlessBlue"), _groupInfo!.GroupAliasOrGID); ImGui.TextDisabled($"ID: {_groupInfo.Group.GID}"); ImGui.TextDisabled($"Owner: {_groupInfo.Owner.AliasOrUID}"); ImGui.EndGroup(); ImGui.Dummy(new Vector2(0f, 4f * scale)); ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale); if (ImGui.BeginChild("##GroupProfileDescriptionPreview", new Vector2(-1f, 120f * scale), true)) { var hasDescription = !string.IsNullOrWhiteSpace(profile.Description); ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + ImGui.GetContentRegionAvail().X); if (!hasDescription) { ImGui.TextDisabled("Syncshell has no description set."); } else if (!SeStringUtils.TryRenderSeStringMarkupAtCursor(profile.Description!)) { UiSharedService.TextWrapped(profile.Description); } ImGui.PopTextWrapPos(); } ImGui.EndChild(); ImGui.PopStyleVar(); ImGui.Dummy(new Vector2(0f, 4f * scale)); ImGui.TextColored(UIColors.Get("LightlessBlue"), "Saved Tags"); var savedTags = _profileTagService.ResolveTags(_profileTagIds); if (savedTags.Count == 0) { ImGui.TextDisabled("-- No tags set --"); } else { bool first = true; for (int i = 0; i < savedTags.Count; i++) { if (!savedTags[i].HasContent) continue; if (!first) ImGui.SameLine(0f, 6f * scale); first = false; using (ImRaii.PushId($"group-snapshot-tag-{i}")) DrawTagPreview(savedTags[i], scale, "##groupSnapshotTagPreview"); } if (!first) ImGui.NewLine(); } } private void DrawGroupProfileImageControls() { _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessBlue"), "Profile pictures must be 512x512 and under 2 MiB."); if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile picture")) { _fileDialogManager.OpenFileDialog("Select syncshell profile picture", ImageFileDialogFilter, (success, file) => { if (!success || string.IsNullOrEmpty(file)) return; _showProfileImageError = false; _ = SubmitGroupProfilePicture(file); }); } UiSharedService.AttachToolTip("Select an image up to 512x512 pixels and <= 2 MiB (PNG/JPG/JPEG/WEBP/BMP)."); ImGui.SameLine(); if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear profile picture")) { _ = ClearGroupProfilePicture(); } UiSharedService.AttachToolTip("Remove the current profile picture from this syncshell."); if (_showProfileImageError) { UiSharedService.ColorTextWrapped("Image must be no larger than 512x512 pixels and under 2 MiB.", ImGuiColors.DalamudRed); } } private void DrawGroupProfileBannerControls() { _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessBlue"), "Profile banners must be 840x260 and under 2 MiB."); if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile banner")) { _fileDialogManager.OpenFileDialog("Select syncshell profile banner", ImageFileDialogFilter, (success, file) => { if (!success || string.IsNullOrEmpty(file)) return; _showBannerImageError = false; _ = SubmitGroupProfileBanner(file); }); } UiSharedService.AttachToolTip("Select an image up to 840x260 pixels and <= 2 MiB (PNG/JPG/JPEG/WEBP/BMP)."); ImGui.SameLine(); if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear profile banner")) { _ = ClearGroupProfileBanner(); } UiSharedService.AttachToolTip("Remove the current profile banner."); if (_showBannerImageError) { UiSharedService.ColorTextWrapped("Banner must be no larger than 840x260 pixels and under 2 MiB.", ImGuiColors.DalamudRed); } } private void DrawGroupProfileDescriptionEditor() { ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(6f, 4f) * ImGuiHelpers.GlobalScale); var descriptionBoxSize = new Vector2(-1f, 160f * ImGuiHelpers.GlobalScale); ImGui.InputTextMultiline("##GroupDescription", ref _descriptionText, 1500, descriptionBoxSize); ImGui.PopStyleVar(); ImGui.TextDisabled($"{_descriptionText.Length}/1500 characters"); ImGui.SameLine(); ImGuiComponents.HelpMarker(DescriptionMacroTooltip); bool changed = !string.Equals(_descriptionText, _profileDescription, StringComparison.Ordinal); if (!changed) ImGui.BeginDisabled(); if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Description")) { _ = SubmitGroupDescription(_descriptionText); } UiSharedService.AttachToolTip("Apply the text above to the syncshell profile description."); if (!changed) ImGui.EndDisabled(); ImGui.SameLine(); if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear Description")) { _ = SubmitGroupDescription(string.Empty); } UiSharedService.AttachToolTip("Remove the profile description."); } private void DrawGroupProfileTagsEditor(float scale) { DrawTagEditor( scale, contextPrefix: "group", saveTooltip: "Apply the selected tags to this syncshell profile.", submitAction: payload => SubmitGroupTagChanges(payload), allowReorder: true, sortPayloadBeforeSubmit: true, onPayloadPrepared: payload => { _tagEditorSelection.Clear(); if (payload.Length > 0) _tagEditorSelection.AddRange(payload); }); } private void DrawGroupProfileVisibilityControls() { bool changedNsfw = DrawCheckboxRow("Profile is NSFW", _groupIsNsfw, out var newNsfw, "Flag this profile as not safe for work."); if (changedNsfw) _groupIsNsfw = newNsfw; bool changedDisabled = DrawCheckboxRow("Disable profile for viewers", _groupIsDisabled, out var newDisabled, "Temporarily hide this profile from members."); if (changedDisabled) _groupIsDisabled = newDisabled; bool visibilityChanged = (_groupIsNsfw != _groupServerIsNsfw) || (_groupIsDisabled != _groupServerIsDisabled); if (!visibilityChanged) ImGui.BeginDisabled(); if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Apply Visibility Changes")) { _ = SubmitGroupVisibilityChanges(_groupIsNsfw, _groupIsDisabled); } UiSharedService.AttachToolTip("Apply the visibility toggles above."); if (!visibilityChanged) ImGui.EndDisabled(); ImGui.SameLine(); if (_uiSharedService.IconTextButton(FontAwesomeIcon.SyncAlt, "Reset")) { _groupIsNsfw = _groupServerIsNsfw; _groupIsDisabled = _groupServerIsDisabled; } } private string? GetCurrentGroupProfileImageBase64() { if (_queuedProfileImage is not null && _queuedProfileImage.Length > 0) return Convert.ToBase64String(_queuedProfileImage); if (!string.IsNullOrWhiteSpace(_groupProfileData?.Base64ProfilePicture)) return _groupProfileData!.Base64ProfilePicture; return _profileImage.Length > 0 ? Convert.ToBase64String(_profileImage) : null; } private string? GetCurrentGroupBannerBase64() { if (_queuedBannerImage is not null && _queuedBannerImage.Length > 0) return Convert.ToBase64String(_queuedBannerImage); if (!string.IsNullOrWhiteSpace(_groupProfileData?.Base64BannerPicture)) return _groupProfileData!.Base64BannerPicture; return _bannerImage.Length > 0 ? Convert.ToBase64String(_bannerImage) : null; } private async Task SubmitGroupProfilePicture(string filePath) { if (_groupInfo is null) return; try { var fileContent = await File.ReadAllBytesAsync(filePath).ConfigureAwait(false); var stream = new MemoryStream(fileContent); await using (stream.ConfigureAwait(false)) { var format = await Image.DetectFormatAsync(stream).ConfigureAwait(false); if (!IsSupportedImageFormat(format)) { _showProfileImageError = true; return; } using var image = Image.Load(fileContent); if (image.Width > 512 || image.Height > 512 || fileContent.Length > 2000 * 1024) { _showProfileImageError = true; return; } await _apiController.GroupSetProfile(new GroupProfileDto( _groupInfo.Group, Description: null, Tags: null, PictureBase64: Convert.ToBase64String(fileContent), BannerBase64: null, IsNsfw: null, IsDisabled: null)).ConfigureAwait(false); _showProfileImageError = false; _queuedProfileImage = fileContent; Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group)); } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to upload syncshell profile picture."); } } private async Task ClearGroupProfilePicture() { if (_groupInfo is null) return; try { await _apiController.GroupSetProfile(new GroupProfileDto( _groupInfo.Group, Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null)).ConfigureAwait(false); _queuedProfileImage = Array.Empty(); Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group)); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to clear syncshell profile picture."); } } private async Task SubmitGroupProfileBanner(string filePath) { if (_groupInfo is null) return; try { var fileContent = await File.ReadAllBytesAsync(filePath).ConfigureAwait(false); await using var stream = new MemoryStream(fileContent); var format = await Image.DetectFormatAsync(stream).ConfigureAwait(false); if (!IsSupportedImageFormat(format)) { _showBannerImageError = true; return; } using var image = Image.Load(fileContent); if (image.Width > 840 || image.Height > 260 || fileContent.Length > 2000 * 1024) { _showBannerImageError = true; return; } await _apiController.GroupSetProfile(new GroupProfileDto( _groupInfo.Group, Description: null, Tags: null, PictureBase64: null, BannerBase64: Convert.ToBase64String(fileContent), IsNsfw: null, IsDisabled: null)).ConfigureAwait(false); _showBannerImageError = false; _queuedBannerImage = fileContent; Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group)); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to upload syncshell profile banner."); } } private async Task ClearGroupProfileBanner() { if (_groupInfo is null) return; try { await _apiController.GroupSetProfile(new GroupProfileDto( _groupInfo.Group, Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null)).ConfigureAwait(false); _queuedBannerImage = Array.Empty(); Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group)); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to clear syncshell profile banner."); } } private async Task SubmitGroupDescription(string description) { if (_groupInfo is null) return; try { await _apiController.GroupSetProfile(new GroupProfileDto( _groupInfo.Group, Description: description, Tags: null, PictureBase64: GetCurrentGroupProfileImageBase64(), BannerBase64: GetCurrentGroupBannerBase64(), IsNsfw: null, IsDisabled: null)).ConfigureAwait(false); _profileDescription = description; Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group)); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to update syncshell profile description."); } } private async Task SubmitGroupTagChanges(int[] payload) { if (_groupInfo is null) return; try { await _apiController.GroupSetProfile(new GroupProfileDto( _groupInfo.Group, Description: null, Tags: payload, PictureBase64: GetCurrentGroupProfileImageBase64(), BannerBase64: GetCurrentGroupBannerBase64(), IsNsfw: null, IsDisabled: null)).ConfigureAwait(false); _profileTagIds = payload.Length == 0 ? Array.Empty() : payload.ToArray(); Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group)); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to update syncshell profile tags."); } } private async Task SubmitGroupVisibilityChanges(bool isNsfw, bool isDisabled) { if (_groupInfo is null) return; try { await _apiController.GroupSetProfile(new GroupProfileDto( _groupInfo.Group, Description: null, Tags: null, PictureBase64: GetCurrentGroupProfileImageBase64(), BannerBase64: GetCurrentGroupBannerBase64(), IsNsfw: isNsfw, IsDisabled: isDisabled)).ConfigureAwait(false); _groupServerIsNsfw = isNsfw; _groupServerIsDisabled = isDisabled; Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group)); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to update syncshell profile visibility."); } } private void ApplyQueuedGroupProfileImage() { if (_queuedProfileImage is null) return; _profileImage = _queuedProfileImage; _pfpTextureWrap?.Dispose(); _pfpTextureWrap = _profileImage.Length > 0 ? _uiSharedService.LoadImage(_profileImage) : null; _queuedProfileImage = null; } private void ApplyQueuedGroupBannerImage() { if (_queuedBannerImage is null) return; _bannerImage = _queuedBannerImage; _bannerTextureWrap?.Dispose(); _bannerTextureWrap = _bannerImage.Length > 0 ? _uiSharedService.LoadImage(_bannerImage) : null; _queuedBannerImage = null; } private void SyncGroupProfileState(LightlessGroupProfileData profile, bool resetSelection) { if (!_profileImage.SequenceEqual(profile.ProfileImageData.Value)) { _profileImage = profile.ProfileImageData.Value; _pfpTextureWrap?.Dispose(); _pfpTextureWrap = _profileImage.Length > 0 ? _uiSharedService.LoadImage(_profileImage) : null; } if (!_bannerImage.SequenceEqual(profile.BannerImageData.Value)) { _bannerImage = profile.BannerImageData.Value; _bannerTextureWrap?.Dispose(); _bannerTextureWrap = _bannerImage.Length > 0 ? _uiSharedService.LoadImage(_bannerImage) : null; } if (!string.Equals(_profileDescription, profile.Description, StringComparison.Ordinal)) { _profileDescription = profile.Description; _descriptionText = _profileDescription; } var tags = profile.Tags ?? Array.Empty(); if (!TagsEqual(tags, _profileTagIds)) { _profileTagIds = tags.Count == 0 ? Array.Empty() : tags.ToArray(); if (resetSelection) { _tagEditorSelection.Clear(); if (_profileTagIds.Length > 0) _tagEditorSelection.AddRange(_profileTagIds); } } _groupIsNsfw = profile.IsNsfw; _groupIsDisabled = profile.IsDisabled; _groupServerIsNsfw = profile.IsNsfw; _groupServerIsDisabled = profile.IsDisabled; } }