Merge branch '1.12.3' into dev
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 33s

This commit is contained in:
defnotken
2025-10-19 15:16:02 -05:00
26 changed files with 1603 additions and 64 deletions

View File

@@ -0,0 +1,178 @@
tagline: "Lightless Sync v1.12.3"
subline: "LightSpeed, Welcome Screen, and More!"
changelog:
- name: "v1.12.3"
tagline: "LightSpeed, Welcome Screen, and More!"
date: "October 15th 2025"
# be sure to set this every new version
isCurrent: true
versions:
- number: "LightSpeed"
icon: ""
items:
- "New way to download that will download mods directly from the file server"
- "LightSpeed is in BETA and should be faster than the batch downloading"
- number: "Welcome Screen + Additional Features"
icon: ""
items:
- "New in-game Patch Notes window."
- "Credits section to thank contributors and supporters."
- "Patch notes only show after updates, not during first-time setup."
- "Syncshell Rework stared: Profiles have been added (more features using this will come later)."
- number: "Notifications"
icon: ""
items:
- "More customizable notification options."
- "Perfomance limiter shows as notifications."
- "All notifications can be configured or disabled in Settings → Notifications."
- number: "Bugfixes"
icon: ""
items:
- "Added more safety checks to nameplates"
- "Removed a line in SyncshellUI potentially causing NullPointers"
- "Additional safety checks in PlayerData.Factory"
- name: "v1.12.2"
tagline: "LightFinder fixes, Notifications overhaul"
date: "October 12th 2025"
versions:
- number: "LightFinder"
icon: ""
items:
- "Server-side improvements for LightFinder functionality."
- "Command changed from '/light lightfinder' to '/light finder'."
- "Option to enable LightFinder on connection (opt-in, refreshes every 3 hours)."
- "LightFinder indicator can now be shown on the server info bar."
- number: "Notifications"
icon: ""
items:
- "Completely reworked notification system with new UI."
- "Pair requests now show as notifications."
- "Download progress shows as notifications."
- "Customizable notification sounds, size, position, and duration."
- "All notifications can be configured or disabled in Settings → Notifications."
- number: "Bug Fixes"
icon: ""
items:
- "Fixed nameplate alignment issues with LightFinder and icons."
- "Icons now properly apply instead of swapping on choice."
- "Updated Discord URL."
- "File cache logic improvements."
- name: "v1.12.1"
tagline: "LightFinder customization and download limiter"
date: "October 8th 2025"
versions:
- number: "New Features"
icon: ""
items:
- "LightFinder text can be modified to an icon with customizable positioning."
- "Option to hide your own indicator or paired player indicators."
- "Pair Download Limiter: Limit simultaneous downloads to 1-6 users to reduce network strain."
- "Added '/light lightfinder' command to open LightFinder UI."
- number: "Improvements"
icon: ""
items:
- "Right-click menu option for Send Pair Request can be disabled."
- "Syncshell finder improvements."
- "Download limiter settings available in Settings → Transfers."
- name: "v1.12.0"
tagline: "LightFinder - Major feature release"
date: "October 5th 2025"
versions:
- number: "Major Features"
icon: ""
items:
- "Introduced LightFinder: Optional feature inspired by FFXIV's Party Finder."
- "Find fellow Lightless users and advertise your Syncshell to others."
- "When enabled, you're visible to other LightFinder users for 3 hours."
- "LightFinder tag displays above your nameplate when active."
- "Receive pair requests directly in UI without exchanging UIDs."
- "Syncshell Finder allows joining indexed Syncshells."
- "[L] Send Pair Request added to player context menus."
- number: "Vanity Features"
icon: ""
items:
- "Supporters can now customize their name color in the Lightless UI."
- "Color changes visible to all users."
- number: "General Improvements"
icon: ""
items:
- "Pairing nameplate color override can now override FC tags."
- "Added .kdb as whitelisted filetype for uploads."
- "Various UI fixes, updates, and improvements."
- name: "v1.11.12"
tagline: "Syncshell grouping and performance options"
date: "September 16th 2025"
versions:
- number: "New Features"
icon: ""
items:
- "Ability to show grouped syncshells in main UI/all syncshells (default ON)."
- "Transfer ownership button available in Admin Panel user list."
- "Self-threshold warning now opens character analysis screen when clicked."
- number: "Performance"
icon: ""
items:
- "Auto-pause combat and auto-pause performance are now optional settings."
- "Both options are auto-enabled by default - disable at your own risk."
- number: "Bug Fixes"
icon: ""
items:
- "Reworked file caching to reduce errors for some users."
- "Fixed bug where exiting PvP could desync some users."
- name: "v1.11.9"
tagline: "File cache improvements"
date: "September 13th 2025"
versions:
- number: "Bug Fixes"
icon: ""
items:
- "Identified and fixed potential file cache problems."
- "Improved cache error handling and stability."
- name: "v1.11.8"
tagline: "Hotfix - UI and exception handling"
date: "September 12th 2025"
versions:
- number: "Bug Fixes"
icon: ""
items:
- "Attempted fix for NullReferenceException spam."
- "Fixed additional UI edge cases preventing loading for some users."
- "Fixed color bar UI issues."
- name: "v1.11.7"
tagline: "Hotfix - UI loading and warnings"
date: "September 12th 2025"
versions:
- number: "Bug Fixes"
icon: ""
items:
- "Fixed UI not loading for some users."
- "Self warnings now behind 'Warn on loading in players exceeding performance thresholds' setting."
- name: "v1.11.6"
tagline: "Admin panel rework and new features"
date: "September 11th 2025"
versions:
- number: "New Features"
icon: ""
items:
- "Reworked Syncshell Admin Page with improved styling."
- "Right-click on Server Top Bar button to disconnect from Lightless."
- "Shift+Left click on Server Top Bar button to open settings."
- "Added colors section in settings to change accent colors."
- "Ability to pause syncing while in Instance/Duty."
- "Functionality to create syncshell folders."
- "Added self-threshold warning."
- number: "Bug Fixes"
icon: ""
items:
- "Fixed owners being visible in moderator list view."
- "Removed Pin/Remove/Ban buttons on Owners when viewing as moderator."
- "Fixed nameplate bug in PvP."
- "Added 1 or 3 day options for inactive check."
- "Fixed bug where some users could not see their own syncshell folders."

View File

@@ -0,0 +1,68 @@
credits:
- category: "Development Team"
items:
- name: "Abel"
role: "Developer"
- name: "Cake"
role: "Developer"
- name: "Celine"
role: "Developer"
- name: "Choco"
role: "Developer"
- name: "Kenny"
role: "Developer"
- name: "Zura"
role: "Developer"
- name: "Additional Contributors"
role: "Community Contributors & Bug Reporters"
- category: "Moderation Team"
items:
- name: "Crow"
role: "Moderator"
- name: "Faith"
role: "Moderator"
- name: "Kiwiwiwi"
role: "Moderator"
- name: "Kruwu"
role: "Moderator"
- name: "Lexi"
role: "Moderator"
- name: "Maya"
role: "Moderator"
- name: "Metaknight"
role: "Moderator"
- name: "Minmoose"
role: "Moderator"
- name: "Nihal"
role: "Moderator"
- name: "Tani"
role: "Moderator"
- category: "Plugin Integration & IPC Support"
items:
- name: "Penumbra Team"
role: "Mod framework integration"
- name: "Glamourer Team"
role: "Customization system integration"
- name: "Customize+ Team"
role: "Body scaling integration"
- name: "Simple Heels Team"
role: "Height offset integration"
- name: "Honorific Team"
role: "Title system integration"
- name: "Moodles Team"
role: "Status effect integration"
- name: "PetNicknames Team"
role: "Pet naming integration"
- name: "Brio Team"
role: "GPose enhancement integration"
- category: "Special Thanks"
items:
- name: "Dalamud & XIVLauncher Teams"
role: "Plugin framework and infrastructure"
- name: "Community Supporters"
role: "Testing, feedback, and financial support"
- name: "Beta Testers"
role: "Early testing and bug reporting"

View File

@@ -147,4 +147,5 @@ public class LightlessConfig : ILightlessConfiguration
public DateTime BroadcastTtl { get; set; } = DateTime.MinValue;
public bool SyncshellFinderEnabled { get; set; } = false;
public string? SelectedFinderSyncshell { get; set; } = null;
public string LastSeenVersion { get; set; } = string.Empty;
}

View File

@@ -9,6 +9,7 @@ using LightlessSync.UI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog;
using System.Reflection;
namespace LightlessSync;
@@ -101,7 +102,7 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
UIColors.Initialize(_lightlessConfigService);
Mediator.StartQueueProcessing();
return Task.CompletedTask;
}
@@ -115,6 +116,24 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
return Task.CompletedTask;
}
private void CheckVersion()
{
var ver = Assembly.GetExecutingAssembly().GetName().Version;
var currentVersion = ver == null ? string.Empty : $"{ver.Major}.{ver.Minor}.{ver.Build}";
var lastSeen = _lightlessConfigService.Current.LastSeenVersion ?? string.Empty;
Logger.LogInformation("Last seen version: {lastSeen}, current version: {currentVersion}", lastSeen, currentVersion);
Logger.LogInformation("User has valid setup: {hasValidSetup}", _lightlessConfigService.Current.HasValidSetup());
Logger.LogInformation("Server has valid config: {hasValidConfig}", _serverConfigurationManager.HasValidConfig());
// Show update notes if version has changed and user has valid setup
if (!string.Equals(lastSeen, currentVersion, StringComparison.Ordinal) &&
_lightlessConfigService.Current.HasValidSetup() &&
_serverConfigurationManager.HasValidConfig())
{
Mediator.Publish(new UiToggleMessage(typeof(UpdateNotesUi)));
}
}
private void DalamudUtilOnLogIn()
{
@@ -154,6 +173,7 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
_runtimeServiceScope.ServiceProvider.GetRequiredService<VisibleUserDataDistributor>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<NotificationService>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<NameplateService>();
CheckVersion();
#if !DEBUG
if (_lightlessConfigService.Current.LogLevel != LogLevel.Information)

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Dalamud.NET.Sdk/13.1.0">
<PropertyGroup>
<Authors></Authors>
@@ -46,6 +46,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
<PropertyGroup>
@@ -64,6 +65,8 @@
<None Update="images\icon.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<EmbeddedResource Include="Changelog\changelog.yaml" />
<EmbeddedResource Include="Changelog\credits.yaml" />
<EmbeddedResource Include="Localization\de.json" />
<EmbeddedResource Include="Localization\fr.json" />
</ItemGroup>

View File

@@ -138,7 +138,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
{
if (_allClientPairs.TryGetValue(user, out var pair))
{
Mediator.Publish(new ClearProfileDataMessage(pair.UserData));
Mediator.Publish(new ClearProfileUserDataMessage(pair.UserData));
pair.MarkOffline();
}
@@ -149,7 +149,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
{
if (!_allClientPairs.ContainsKey(dto.User)) throw new InvalidOperationException("No user found for " + dto);
Mediator.Publish(new ClearProfileDataMessage(dto.User));
Mediator.Publish(new ClearProfileUserDataMessage(dto.User));
var pair = _allClientPairs[dto.User];
if (pair.HasCachedPlayer)
@@ -254,7 +254,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
if (pair.UserPair.OtherPermissions.IsPaused() != dto.Permissions.IsPaused())
{
Mediator.Publish(new ClearProfileDataMessage(dto.User));
Mediator.Publish(new ClearProfileUserDataMessage(dto.User));
}
pair.UserPair.OtherPermissions = dto.Permissions;
@@ -280,7 +280,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
if (pair.UserPair.OwnPermissions.IsPaused() != dto.Permissions.IsPaused())
{
Mediator.Publish(new ClearProfileDataMessage(dto.User));
Mediator.Publish(new ClearProfileUserDataMessage(dto.User));
}
pair.UserPair.OwnPermissions = dto.Permissions;

View File

@@ -246,6 +246,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddScoped<WindowMediatorSubscriberBase, CreateSyncshellUI>();
collection.AddScoped<WindowMediatorSubscriberBase, EventViewerUI>();
collection.AddScoped<WindowMediatorSubscriberBase, CharaDataHubUi>();
collection.AddScoped<WindowMediatorSubscriberBase, UpdateNotesUi>();
collection.AddScoped<WindowMediatorSubscriberBase, EditProfileUi>((s) => new EditProfileUi(s.GetRequiredService<ILogger<EditProfileUi>>(),
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<FileDialogManager>(),

View File

@@ -1,4 +1,8 @@
using LightlessSync.API.Dto.Group;
using Dalamud.Interface;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.UI;
using LightlessSync.UI.Models;
using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.Mediator;
@@ -140,6 +144,11 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
IsLightFinderAvailable = false;
ApplyBroadcastDisabled(forcePublish: true);
_logger.LogDebug("Cleared Lightfinder state due to disconnect.");
_mediator.Publish(new NotificationMessage(
"Disconnected from Server",
"Your Lightfinder broadcast has been disabled due to disconnection.",
NotificationType.Warning));
}
public Task StartAsync(CancellationToken cancellationToken)
@@ -236,6 +245,11 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
{
_logger.LogInformation("Auto-enabling Lightfinder broadcast after reconnect.");
_mediator.Publish(new EnableBroadcastMessage(hashedCid, true));
_mediator.Publish(new NotificationMessage(
"Broadcast Auto-Enabled",
"Your Lightfinder broadcast has been automatically enabled.",
NotificationType.Info));
}
}
catch (OperationCanceledException)
@@ -391,9 +405,14 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
public async void ToggleBroadcast()
{
if (!IsLightFinderAvailable)
{
_logger.LogWarning("ToggleBroadcast - Lightfinder is not available.");
_mediator.Publish(new NotificationMessage(
"Broadcast Unavailable",
"Lightfinder is not available on this server.",
NotificationType.Error));
return;
}
@@ -403,6 +422,10 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
if (!_config.Current.BroadcastEnabled && cooldown is { } cd && cd > TimeSpan.Zero)
{
_logger.LogWarning("Cooldown active. Must wait {Remaining}s before re-enabling.", cd.TotalSeconds);
_mediator.Publish(new NotificationMessage(
"Broadcast Cooldown",
$"Please wait {cd.TotalSeconds:F0} seconds before re-enabling broadcast.",
NotificationType.Warning));
return;
}
@@ -427,10 +450,19 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
_logger.LogDebug("Toggling broadcast. Server currently broadcasting: {ServerStatus}, setting to: {NewStatus}", isCurrentlyBroadcasting, newStatus);
_mediator.Publish(new EnableBroadcastMessage(hashedCid, newStatus));
_mediator.Publish(new NotificationMessage(
newStatus ? "Broadcast Enabled" : "Broadcast Disabled",
newStatus ? "Your Lightfinder broadcast has been enabled." : "Your Lightfinder broadcast has been disabled.",
NotificationType.Info));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to determine current broadcast status for toggle");
_mediator.Publish(new NotificationMessage(
"Broadcast Toggle Failed",
$"Failed to toggle broadcast: {ex.Message}",
NotificationType.Error));
}
}).ConfigureAwait(false);
}
@@ -493,6 +525,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
{
_logger.LogDebug("Broadcast TTL expired. Disabling broadcast locally.");
ApplyBroadcastDisabled(forcePublish: true);
ShowBroadcastExpiredNotification();
}
}
else
@@ -501,4 +534,49 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
}
}).ConfigureAwait(false);
}
private void ShowBroadcastExpiredNotification()
{
var notification = new LightlessNotification
{
Id = "broadcast_expired",
Title = "Broadcast Expired",
Message = "Your Lightfinder broadcast has expired after 3 hours. Would you like to re-enable it?",
Type = NotificationType.PairRequest,
Duration = TimeSpan.FromSeconds(180),
Actions = new List<LightlessNotificationAction>
{
new()
{
Id = "re_enable",
Label = "Re-enable",
Icon = FontAwesomeIcon.Plus,
Color = UIColors.Get("PairBlue"),
IsPrimary = true,
OnClick = (n) =>
{
_logger.LogInformation("Re-enabling broadcast from notification");
ToggleBroadcast();
n.IsDismissed = true;
n.IsAnimatingOut = true;
}
},
new()
{
Id = "close",
Label = "Close",
Icon = FontAwesomeIcon.Times,
Color = UIColors.Get("DimRed"),
OnClick = (n) =>
{
_logger.LogInformation("Broadcast expiration notification dismissed");
n.IsDismissed = true;
n.IsAnimatingOut = true;
}
}
}
};
_mediator.Publish(new LightlessNotificationMessage(notification));
}
}

View File

@@ -0,0 +1,6 @@
namespace LightlessSync.Services;
public record LightlessGroupProfileData(string Base64ProfilePicture, string Description, int[] Tags, bool IsNsfw, bool IsDisabled)
{
public Lazy<byte[]> ImageData { get; } = new Lazy<byte[]>(Convert.FromBase64String(Base64ProfilePicture));
}

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
namespace LightlessSync.Services;
public record LightlessProfileData(bool IsFlagged, bool IsNSFW, string Base64ProfilePicture, string Base64SupporterPicture, string Description)
public record LightlessUserProfileData(bool IsFlagged, bool IsNSFW, string Base64ProfilePicture, string Base64SupporterPicture, string Description)
{
public Lazy<byte[]> ImageData { get; } = new Lazy<byte[]>(Convert.FromBase64String(Base64ProfilePicture));
public Lazy<byte[]> SupporterImageData { get; } = new Lazy<byte[]>(string.IsNullOrEmpty(Base64SupporterPicture) ? [] : Convert.FromBase64String(Base64SupporterPicture));

View File

@@ -70,7 +70,8 @@ public record DownloadStartedMessage(GameObjectHandler DownloadId, Dictionary<st
public record DownloadFinishedMessage(GameObjectHandler DownloadId) : MessageBase;
public record UiToggleMessage(Type UiType) : MessageBase;
public record PlayerUploadingMessage(GameObjectHandler Handler, bool IsUploading) : MessageBase;
public record ClearProfileDataMessage(UserData? UserData = null) : MessageBase;
public record ClearProfileUserDataMessage(UserData? UserData = null) : MessageBase;
public record ClearProfileGroupDataMessage(GroupData? GroupData = null) : MessageBase;
public record CyclePauseMessage(UserData UserData) : MessageBase;
public record PauseMessage(UserData UserData) : MessageBase;
public record ProfilePopoutToggle(Pair? Pair) : MessageBase;

View File

@@ -1,4 +1,5 @@
using LightlessSync.API.Dto.Group;
using Dalamud.Interface.ImGuiFileDialog;
using LightlessSync.API.Dto.Group;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
@@ -18,10 +19,11 @@ public class UiFactory
private readonly ServerConfigurationManager _serverConfigManager;
private readonly LightlessProfileManager _lightlessProfileManager;
private readonly PerformanceCollectorService _performanceCollectorService;
private readonly FileDialogManager _fileDialogManager;
public UiFactory(ILoggerFactory loggerFactory, LightlessMediator lightlessMediator, ApiController apiController,
UiSharedService uiSharedService, PairManager pairManager, ServerConfigurationManager serverConfigManager,
LightlessProfileManager lightlessProfileManager, PerformanceCollectorService performanceCollectorService)
LightlessProfileManager lightlessProfileManager, PerformanceCollectorService performanceCollectorService, FileDialogManager fileDialogManager)
{
_loggerFactory = loggerFactory;
_lightlessMediator = lightlessMediator;
@@ -31,12 +33,13 @@ public class UiFactory
_serverConfigManager = serverConfigManager;
_lightlessProfileManager = lightlessProfileManager;
_performanceCollectorService = performanceCollectorService;
_fileDialogManager = fileDialogManager;
}
public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto)
{
return new SyncshellAdminUI(_loggerFactory.CreateLogger<SyncshellAdminUI>(), _lightlessMediator,
_apiController, _uiSharedService, _pairManager, dto, _performanceCollectorService);
_apiController, _uiSharedService, _pairManager, dto, _performanceCollectorService, _lightlessProfileManager, _fileDialogManager);
}
public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair)

View File

@@ -63,7 +63,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
Mediator.Subscribe<GposeStartMessage>(this, (_) => { _wasOpen = IsOpen; IsOpen = false; });
Mediator.Subscribe<GposeEndMessage>(this, (_) => IsOpen = _wasOpen);
Mediator.Subscribe<DisconnectedMessage>(this, (_) => IsOpen = false);
Mediator.Subscribe<ClearProfileDataMessage>(this, (msg) =>
Mediator.Subscribe<ClearProfileUserDataMessage>(this, (msg) =>
{
if (msg.UserData == null || string.Equals(msg.UserData.UID, _apiController.UID, StringComparison.Ordinal))
{
@@ -91,6 +91,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
protected override void DrawInternal()
{
_uiSharedService.UnderlinedBigText("Notes and Rules for Profiles", UIColors.Get("LightlessYellow"));
ImGui.Dummy(new Vector2(5));
@@ -108,7 +109,8 @@ public class EditProfileUi : WindowMediatorSubscriberBase
ImGui.Dummy(new Vector2(3));
var profile = _lightlessProfileManager.GetLightlessProfile(new UserData(_apiController.UID));
var profile = _lightlessProfileManager.GetLightlessUserProfile(new UserData(_apiController.UID));
_logger.LogInformation("Profile fetched for drawing: {profile}", profile);
if (ImGui.BeginTabBar("##EditProfileTabs"))
{
@@ -204,7 +206,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
}
_showFileDialogError = false;
await _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, Convert.ToBase64String(fileContent), Description: null))
await _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, Convert.ToBase64String(fileContent), Description: null, Tags: null))
.ConfigureAwait(false);
});
});
@@ -213,7 +215,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
ImGui.SameLine();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear uploaded profile picture"))
{
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, "", Description: null));
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, "", Description: null, Tags: null));
}
UiSharedService.AttachToolTip("Clear your currently uploaded profile picture");
if (_showFileDialogError)
@@ -223,7 +225,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
var isNsfw = profile.IsNSFW;
if (ImGui.Checkbox("Profile is NSFW", ref isNsfw))
{
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, isNsfw, ProfilePictureBase64: null, Description: null));
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, isNsfw, ProfilePictureBase64: null, Description: null, Tags: null));
}
_uiSharedService.DrawHelpText("If your profile description or image can be considered NSFW, toggle this to ON");
var widthTextBox = 400;
@@ -262,13 +264,13 @@ public class EditProfileUi : WindowMediatorSubscriberBase
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Description"))
{
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: null, _descriptionText));
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: null, _descriptionText, Tags: null));
}
UiSharedService.AttachToolTip("Sets your profile description text");
ImGui.SameLine();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear Description"))
{
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: null, ""));
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: null, "", Tags: null));
}
UiSharedService.AttachToolTip("Clears your profile description text");

View File

@@ -0,0 +1,43 @@
namespace LightlessSync.UI.Models
{
public class ChangelogFile
{
public string Tagline { get; init; } = string.Empty;
public string Subline { get; init; } = string.Empty;
public List<ChangelogEntry> Changelog { get; init; } = new();
public List<CreditCategory>? Credits { get; init; }
}
public class ChangelogEntry
{
public string Name { get; init; } = string.Empty;
public string Date { get; init; } = string.Empty;
public string Tagline { get; init; } = string.Empty;
public bool? IsCurrent { get; init; }
public string? Message { get; init; }
public List<ChangelogVersion>? Versions { get; init; }
}
public class ChangelogVersion
{
public string Number { get; init; } = string.Empty;
public List<string> Items { get; init; } = new();
}
public class CreditCategory
{
public string Category { get; init; } = string.Empty;
public List<CreditItem> Items { get; init; } = new();
}
public class CreditItem
{
public string Name { get; init; } = string.Empty;
public string Role { get; init; } = string.Empty;
}
public class CreditsFile
{
public List<CreditCategory> Credits { get; init; } = new();
}
}

View File

@@ -85,7 +85,7 @@ public class PopoutProfileUi : WindowMediatorSubscriberBase
{
var spacing = ImGui.GetStyle().ItemSpacing;
var lightlessProfile = _lightlessProfileManager.GetLightlessProfile(_pair.UserData);
var lightlessProfile = _lightlessProfileManager.GetLightlessUserProfile(_pair.UserData);
if (_textureWrap == null || !lightlessProfile.ImageData.Value.SequenceEqual(_lastProfilePicture))
{

View File

@@ -0,0 +1,12 @@
namespace LightlessSync.UI
{
public enum ProfileTags
{
SFW = 0,
NSFW = 1,
RP = 2,
ERP = 3,
Venues = 4,
Gpose = 5
}
}

View File

@@ -2302,7 +2302,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
{
if (ImGui.Checkbox("Show Lightless Profiles on Hover", ref showProfiles))
{
Mediator.Publish(new ClearProfileDataMessage());
Mediator.Publish(new ClearProfileUserDataMessage());
_configService.Current.ProfilesShow = showProfiles;
_configService.Save();
}
@@ -2329,7 +2329,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.Unindent();
if (ImGui.Checkbox("Show profiles marked as NSFW", ref showNsfwProfiles))
{
Mediator.Publish(new ClearProfileDataMessage());
Mediator.Publish(new ClearProfileUserDataMessage());
_configService.Current.ProfilesAllowNsfw = showNsfwProfiles;
_configService.Save();
}

View File

@@ -51,7 +51,7 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase
{
var spacing = ImGui.GetStyle().ItemSpacing;
var lightlessProfile = _lightlessProfileManager.GetLightlessProfile(Pair.UserData);
var lightlessProfile = _lightlessProfileManager.GetLightlessUserProfile(Pair.UserData);
if (_textureWrap == null || !lightlessProfile.ImageData.Value.SequenceEqual(_lastProfilePicture))
{

View File

@@ -1,17 +1,26 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Colors;
using Dalamud.Interface.ImGuiFileDialog;
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.API.Dto.User;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.UI.Handlers;
using LightlessSync.WebAPI;
using Microsoft.Extensions.Logging;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using System.Globalization;
using System.Linq;
using System.Numerics;
namespace LightlessSync.UI;
@@ -22,29 +31,51 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
private readonly bool _isOwner = false;
private readonly List<string> _oneTimeInvites = [];
private readonly PairManager _pairManager;
private readonly LightlessProfileManager _lightlessProfileManager;
private readonly FileDialogManager _fileDialogManager;
private readonly UiSharedService _uiSharedService;
private List<BannedGroupUserDto> _bannedUsers = [];
private LightlessGroupProfileData? _profileData = null;
private bool _adjustedForScollBarsLocalProfile = false;
private bool _adjustedForScollBarsOnlineProfile = false;
private string _descriptionText = string.Empty;
private IDalamudTextureWrap? _pfpTextureWrap;
private string _profileDescription = string.Empty;
private byte[] _profileImage = [];
private bool _showFileDialogError = false;
private int _multiInvites;
private string _newPassword;
private bool _pwChangeSuccess;
private Task<int>? _pruneTestTask;
private Task<int>? _pruneTask;
private int _pruneDays = 14;
private List<int> _selectedTags = [];
public SyncshellAdminUI(ILogger<SyncshellAdminUI> logger, LightlessMediator mediator, ApiController apiController,
UiSharedService uiSharedService, PairManager pairManager, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService)
UiSharedService uiSharedService, PairManager pairManager, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, LightlessProfileManager lightlessProfileManager, FileDialogManager fileDialogManager)
: base(logger, mediator, "Syncshell Admin Panel (" + groupFullInfo.GroupAliasOrGID + ")", performanceCollectorService)
{
GroupFullInfo = groupFullInfo;
_apiController = apiController;
_uiSharedService = uiSharedService;
_pairManager = pairManager;
_lightlessProfileManager = lightlessProfileManager;
_fileDialogManager = fileDialogManager;
_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),
@@ -58,10 +89,13 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
{
if (!_isModerator && !_isOwner) return;
_logger.LogTrace("Drawing Syncshell Admin UI for {group}", GroupFullInfo.GroupAliasOrGID);
GroupFullInfo = _pairManager.Groups[GroupFullInfo.Group];
using var id = ImRaii.PushId("syncshell_admin_" + GroupFullInfo.GID);
_profileData = _lightlessProfileManager.GetLightlessGroupProfile(GroupFullInfo.Group);
GetTagsFromProfile();
using var id = ImRaii.PushId("syncshell_admin_" + GroupFullInfo.GID);
using (_uiSharedService.UidFont.Push())
_uiSharedService.UnderlinedBigText(GroupFullInfo.GroupAliasOrGID + " Administrative Panel", UIColors.Get("LightlessBlue"));
@@ -69,14 +103,16 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
var perm = GroupFullInfo.GroupPermissions;
using var tabbar = ImRaii.TabBar("syncshell_tab_" + GroupFullInfo.GID);
if (tabbar)
{
DrawInvites(perm);
DrawManagement();
DrawPermission(perm);
DrawPermission(perm);
DrawProfile();
}
}
@@ -176,6 +212,184 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
ownerTab.Dispose();
}
}
private void DrawProfile()
{
var profileTab = ImRaii.TabItem("Profile");
if (profileTab)
{
if (_uiSharedService.MediumTreeNode("Current Profile", UIColors.Get("LightlessPurple")))
{
ImGui.Dummy(new Vector2(5));
if (!_profileImage.SequenceEqual(_profileData.ImageData.Value))
{
_profileImage = _profileData.ImageData.Value;
_pfpTextureWrap?.Dispose();
_pfpTextureWrap = _uiSharedService.LoadImage(_profileImage);
}
if (!string.Equals(_profileDescription, _profileData.Description, StringComparison.OrdinalIgnoreCase))
{
_profileDescription = _profileData.Description;
_descriptionText = _profileDescription;
}
if (_pfpTextureWrap != null)
{
ImGui.Image(_pfpTextureWrap.Handle, ImGuiHelpers.ScaledVector2(_pfpTextureWrap.Width, _pfpTextureWrap.Height));
}
var spacing = ImGui.GetStyle().ItemSpacing.X;
ImGuiHelpers.ScaledRelativeSameLine(256, spacing);
using (_uiSharedService.GameFont.Push())
{
var descriptionTextSize = ImGui.CalcTextSize(_profileData.Description, wrapWidth: 256f);
var childFrame = ImGuiHelpers.ScaledVector2(256 + ImGui.GetStyle().WindowPadding.X + ImGui.GetStyle().WindowBorderSize, 256);
if (descriptionTextSize.Y > childFrame.Y)
{
_adjustedForScollBarsOnlineProfile = true;
}
else
{
_adjustedForScollBarsOnlineProfile = false;
}
childFrame = childFrame with
{
X = childFrame.X + (_adjustedForScollBarsOnlineProfile ? ImGui.GetStyle().ScrollbarSize : 0),
};
if (ImGui.BeginChildFrame(101, childFrame))
{
UiSharedService.TextWrapped(_profileData.Description);
}
ImGui.EndChildFrame();
ImGui.TreePop();
}
var nsfw = _profileData.IsNsfw;
ImGui.BeginDisabled();
ImGui.Checkbox("Is NSFW", ref nsfw);
ImGui.EndDisabled();
}
ImGui.Separator();
if (_uiSharedService.MediumTreeNode("Profile Settings", UIColors.Get("LightlessPurple")))
{
ImGui.Dummy(new Vector2(5));
ImGui.TextUnformatted($"Profile Picture:");
if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile picture"))
{
_fileDialogManager.OpenFileDialog("Select new Profile picture", ".png", (success, file) =>
{
if (!success) return;
_ = Task.Run(async () =>
{
var fileContent = await File.ReadAllBytesAsync(file).ConfigureAwait(false);
MemoryStream ms = new(fileContent);
await using (ms.ConfigureAwait(false))
{
var format = await Image.DetectFormatAsync(ms).ConfigureAwait(false);
if (!format.FileExtensions.Contains("png", StringComparer.OrdinalIgnoreCase))
{
_showFileDialogError = true;
return;
}
using var image = Image.Load<Rgba32>(fileContent);
if (image.Width > 512 || image.Height > 512 || (fileContent.Length > 2000 * 1024))
{
_showFileDialogError = true;
return;
}
_showFileDialogError = false;
await _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, Convert.ToBase64String(fileContent), IsNsfw: null, IsDisabled: null))
.ConfigureAwait(false);
}
});
});
}
UiSharedService.AttachToolTip("Select and upload a new profile picture");
ImGui.SameLine();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear uploaded profile picture"))
{
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, PictureBase64: null, IsNsfw: null, IsDisabled: null));
}
UiSharedService.AttachToolTip("Clear your currently uploaded profile picture");
if (_showFileDialogError)
{
UiSharedService.ColorTextWrapped("The profile picture must be a PNG file with a maximum height and width of 256px and 250KiB size", ImGuiColors.DalamudRed);
}
ImGui.Separator();
ImGui.TextUnformatted($"Tags:");
var childFrameLocal = ImGuiHelpers.ScaledVector2(256 + ImGui.GetStyle().WindowPadding.X + ImGui.GetStyle().WindowBorderSize, 200);
var allCategoryIndexes = Enum.GetValues<ProfileTags>()
.Cast<int>()
.ToList();
foreach(int tag in allCategoryIndexes)
{
using (ImRaii.PushId($"tag-{tag}")) DrawTag(tag);
}
ImGui.Separator();
var widthTextBox = 400;
var posX = ImGui.GetCursorPosX();
ImGui.TextUnformatted($"Description {_descriptionText.Length}/1500");
ImGui.SetCursorPosX(posX);
ImGuiHelpers.ScaledRelativeSameLine(widthTextBox, ImGui.GetStyle().ItemSpacing.X);
ImGui.TextUnformatted("Preview (approximate)");
using (_uiSharedService.GameFont.Push())
ImGui.InputTextMultiline("##description", ref _descriptionText, 1500, ImGuiHelpers.ScaledVector2(widthTextBox, 200));
ImGui.SameLine();
using (_uiSharedService.GameFont.Push())
{
var descriptionTextSizeLocal = ImGui.CalcTextSize(_descriptionText, wrapWidth: 256f);
if (descriptionTextSizeLocal.Y > childFrameLocal.Y)
{
_adjustedForScollBarsLocalProfile = true;
}
else
{
_adjustedForScollBarsLocalProfile = false;
}
childFrameLocal = childFrameLocal with
{
X = childFrameLocal.X + (_adjustedForScollBarsLocalProfile ? ImGui.GetStyle().ScrollbarSize : 0),
};
if (ImGui.BeginChildFrame(102, childFrameLocal))
{
UiSharedService.TextWrapped(_descriptionText);
}
ImGui.EndChildFrame();
}
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Description"))
{
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: _descriptionText, Tags: null, PictureBase64: null, IsNsfw: null, IsDisabled: null));
}
UiSharedService.AttachToolTip("Sets your profile description text");
ImGui.SameLine();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear Description"))
{
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, PictureBase64: null, IsNsfw: null, IsDisabled: null));
}
UiSharedService.AttachToolTip("Clears your profile description text");
ImGui.Separator();
ImGui.TextUnformatted($"Profile Options:");
var isNsfw = _profileData.IsNsfw;
if (ImGui.Checkbox("Profile is NSFW", ref isNsfw))
{
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, PictureBase64: null, IsNsfw: isNsfw, IsDisabled: null));
}
_uiSharedService.DrawHelpText("If your profile description or image can be considered NSFW, toggle this to ON");
ImGui.TreePop();
}
}
profileTab.Dispose();
}
private void DrawManagement()
{
@@ -192,7 +406,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
{
var tableFlags = ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingStretchProp;
if (pairs.Count > 10) tableFlags |= ImGuiTableFlags.ScrollY;
using var table = ImRaii.Table("userList#" + GroupFullInfo.Group.GID, 3, tableFlags);
using var table = ImRaii.Table("userList#" + GroupFullInfo.Group.AliasOrGID, 3, tableFlags);
if (table)
{
ImGui.TableSetupColumn("Alias/UID/Note", ImGuiTableColumnFlags.None, 4);
@@ -474,7 +688,6 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
ImGui.Separator();
}
mgmtTab.Dispose();
}
private void DrawInvites(GroupPermissions perm)
@@ -521,9 +734,37 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
}
inviteTab.Dispose();
}
private void DrawTag(int tag)
{
var HasTag = _selectedTags.Contains(tag);
var tagName = (ProfileTags)tag;
if (ImGui.Checkbox(tagName.ToString(), ref HasTag))
{
if (HasTag)
{
_selectedTags.Add(tag);
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: _selectedTags.ToArray(), PictureBase64: null, IsNsfw: null, IsDisabled: null));
}
else
{
_selectedTags.Remove(tag);
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: _selectedTags.ToArray(), PictureBase64: null, IsNsfw: null, IsDisabled: null));
}
}
}
private void GetTagsFromProfile()
{
if (_profileData != null)
{
_selectedTags = [.. _profileData.Tags];
}
}
public override void OnClose()
{
Mediator.Publish(new RemoveWindowMessage(this));
_pfpTextureWrap?.Dispose();
}
}

View File

@@ -0,0 +1,751 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Utility;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
using System.Numerics;
using System.Reflection;
using System.Text;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
using Dalamud.Interface;
using LightlessSync.UI.Models;
namespace LightlessSync.UI;
// Inspiration taken from Brio and Character Select+ (goats)
public class UpdateNotesUi : WindowMediatorSubscriberBase
{
private readonly UiSharedService _uiShared;
private readonly LightlessConfigService _configService;
private ChangelogFile _changelog = new();
private CreditsFile _credits = new();
private bool _scrollToTop;
private int _selectedTab;
private bool _hasInitializedCollapsingHeaders;
private struct Particle
{
public Vector2 Position;
public Vector2 Velocity;
public float Life;
public float MaxLife;
public float Size;
public ParticleType Type;
public List<Vector2>? Trail;
public float Twinkle;
public float Depth;
public float Hue;
}
private enum ParticleType
{
TwinklingStar,
ShootingStar
}
private readonly List<Particle> _particles = [];
private float _particleSpawnTimer;
private readonly Random _random = new();
private const float HeaderHeight = 150f;
private const float ParticleSpawnInterval = 0.2f;
private const int MaxParticles = 50;
private const int MaxTrailLength = 50;
private const float EdgeFadeDistance = 30f;
private const float ExtendedParticleHeight = 40f;
public UpdateNotesUi(ILogger<UpdateNotesUi> logger,
LightlessMediator mediator,
UiSharedService uiShared,
LightlessConfigService configService,
PerformanceCollectorService performanceCollectorService)
: base(logger, mediator, "Lightless Sync — Update Notes", performanceCollectorService)
{
logger.LogInformation("UpdateNotesUi constructor called");
_uiShared = uiShared;
_configService = configService;
AllowClickthrough = false;
AllowPinning = false;
RespectCloseHotkey = true;
ShowCloseButton = true;
Flags = ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoCollapse |
ImGuiWindowFlags.NoTitleBar;
SizeConstraints = new WindowSizeConstraints()
{
MinimumSize = new Vector2(800, 700), MaximumSize = new Vector2(800, 700),
};
LoadEmbeddedResources();
logger.LogInformation("UpdateNotesUi constructor completed successfully");
}
public override void OnOpen()
{
_scrollToTop = true;
_hasInitializedCollapsingHeaders = false;
}
protected override void DrawInternal()
{
if (_uiShared.IsInGpose)
return;
DrawHeader();
ImGuiHelpers.ScaledDummy(6);
DrawTabs();
DrawCloseButton();
}
private void DrawHeader()
{
var windowPos = ImGui.GetWindowPos();
var windowPadding = ImGui.GetStyle().WindowPadding;
var headerWidth = (800f * ImGuiHelpers.GlobalScale) - (windowPadding.X * 2);
var headerStart = windowPos + new Vector2(windowPadding.X, windowPadding.Y);
var headerEnd = headerStart + new Vector2(headerWidth, HeaderHeight);
var extendedParticleSize = new Vector2(headerWidth, HeaderHeight + ExtendedParticleHeight);
DrawGradientBackground(headerStart, headerEnd);
DrawHeaderText(headerStart);
DrawHeaderButtons(headerStart, headerWidth);
DrawBottomGradient(headerStart, headerEnd, headerWidth);
ImGui.SetCursorPosY(windowPadding.Y + HeaderHeight + 5);
ImGui.SetCursorPosX(20);
using (ImRaii.PushFont(UiBuilder.IconFont))
{
ImGui.TextColored(UIColors.Get("LightlessGreen"), FontAwesomeIcon.Star.ToIconString());
}
ImGui.SameLine();
ImGui.TextColored(UIColors.Get("LightlessGreen"), "What's New");
if (!string.IsNullOrEmpty(_changelog.Tagline))
{
ImGui.SameLine();
ImGui.SetCursorPosX(ImGui.GetCursorPosX() + 10);
ImGui.TextColored(new Vector4(0.75f, 0.75f, 0.85f, 1.0f), _changelog.Tagline);
if (!string.IsNullOrEmpty(_changelog.Subline))
{
ImGui.SameLine();
ImGui.TextColored(new Vector4(0.65f, 0.65f, 0.75f, 1.0f), $" {_changelog.Subline}");
}
}
ImGuiHelpers.ScaledDummy(3);
DrawParticleEffects(headerStart, extendedParticleSize);
}
private void DrawGradientBackground(Vector2 headerStart, Vector2 headerEnd)
{
var drawList = ImGui.GetWindowDrawList();
var darkPurple = new Vector4(0.08f, 0.05f, 0.15f, 1.0f);
var deepPurple = new Vector4(0.12f, 0.08f, 0.20f, 1.0f);
drawList.AddRectFilledMultiColor(
headerStart,
headerEnd,
ImGui.GetColorU32(darkPurple),
ImGui.GetColorU32(darkPurple),
ImGui.GetColorU32(deepPurple),
ImGui.GetColorU32(deepPurple)
);
var random = new Random(42);
for (int i = 0; i < 50; i++)
{
var starPos = headerStart + new Vector2(
(float)random.NextDouble() * (headerEnd.X - headerStart.X),
(float)random.NextDouble() * (headerEnd.Y - headerStart.Y)
);
var brightness = 0.3f + (float)random.NextDouble() * 0.4f;
drawList.AddCircleFilled(starPos, 1f, ImGui.GetColorU32(new Vector4(1f, 1f, 1f, brightness)));
}
}
private void DrawBottomGradient(Vector2 headerStart, Vector2 headerEnd, float width)
{
var drawList = ImGui.GetWindowDrawList();
var gradientHeight = 60f;
for (int i = 0; i < gradientHeight; i++)
{
var progress = i / gradientHeight;
var smoothProgress = progress * progress;
var r = 0.12f + (0.0f - 0.12f) * smoothProgress;
var g = 0.08f + (0.0f - 0.08f) * smoothProgress;
var b = 0.20f + (0.0f - 0.20f) * smoothProgress;
var alpha = 1f - smoothProgress;
var gradientColor = new Vector4(r, g, b, alpha);
drawList.AddLine(
new Vector2(headerStart.X, headerEnd.Y + i),
new Vector2(headerStart.X + width, headerEnd.Y + i),
ImGui.GetColorU32(gradientColor),
1f
);
}
}
private void DrawHeaderText(Vector2 headerStart)
{
var textX = 20f;
var textY = 30f;
ImGui.SetCursorScreenPos(headerStart + new Vector2(textX, textY));
using (_uiShared.UidFont.Push())
{
ImGui.TextColored(new Vector4(0.95f, 0.95f, 0.95f, 1.0f), "Lightless Sync");
}
ImGui.SetCursorScreenPos(headerStart + new Vector2(textX, textY + 45f));
ImGui.TextColored(UIColors.Get("LightlessBlue"), "Update Notes");
}
private void DrawHeaderButtons(Vector2 headerStart, float headerWidth)
{
var buttonSize = _uiShared.GetIconButtonSize(FontAwesomeIcon.Globe);
var spacing = 8f * ImGuiHelpers.GlobalScale;
var rightPadding = 15f * ImGuiHelpers.GlobalScale;
var topPadding = 15f * ImGuiHelpers.GlobalScale;
var buttonY = headerStart.Y + topPadding;
var gitButtonX = headerStart.X + headerWidth - rightPadding - buttonSize.X;
var discordButtonX = gitButtonX - buttonSize.X - spacing;
ImGui.SetCursorScreenPos(new Vector2(discordButtonX, buttonY));
using (ImRaii.PushColor(ImGuiCol.Button, new Vector4(0, 0, 0, 0)))
using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurple") with { W = 0.3f }))
using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessPurpleActive") with { W = 0.5f }))
{
if (_uiShared.IconButton(FontAwesomeIcon.Comments))
{
Util.OpenLink("https://discord.gg/dsbjcXMnhA");
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Join our Discord");
}
ImGui.SetCursorScreenPos(new Vector2(gitButtonX, buttonY));
if (_uiShared.IconButton(FontAwesomeIcon.Code))
{
Util.OpenLink("https://git.lightless-sync.org/Lightless-Sync");
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("View on Git");
}
}
}
private void DrawParticleEffects(Vector2 bannerStart, Vector2 bannerSize)
{
var deltaTime = ImGui.GetIO().DeltaTime;
_particleSpawnTimer += deltaTime;
if (_particleSpawnTimer > ParticleSpawnInterval && _particles.Count < MaxParticles)
{
SpawnParticle(bannerSize);
_particleSpawnTimer = 0f;
}
if (_random.NextDouble() < 0.003)
{
SpawnShootingStar(bannerSize);
}
var drawList = ImGui.GetWindowDrawList();
for (int i = _particles.Count - 1; i >= 0; i--)
{
var particle = _particles[i];
var screenPos = bannerStart + particle.Position;
if (particle.Type == ParticleType.ShootingStar && particle.Trail != null)
{
particle.Trail.Insert(0, particle.Position);
if (particle.Trail.Count > MaxTrailLength)
particle.Trail.RemoveAt(particle.Trail.Count - 1);
}
if (particle.Type == ParticleType.TwinklingStar)
{
particle.Twinkle += 0.005f * particle.Depth;
}
particle.Position += particle.Velocity * deltaTime;
particle.Life -= deltaTime;
var isOutOfBounds = particle.Position.X < -50 || particle.Position.X > bannerSize.X + 50 ||
particle.Position.Y < -50 || particle.Position.Y > bannerSize.Y + 50;
if (particle.Life <= 0 || (particle.Type != ParticleType.TwinklingStar && isOutOfBounds))
{
_particles.RemoveAt(i);
continue;
}
if (particle.Type == ParticleType.TwinklingStar)
{
if (particle.Position.X < 0 || particle.Position.X > bannerSize.X)
particle.Velocity = particle.Velocity with { X = -particle.Velocity.X };
if (particle.Position.Y < 0 || particle.Position.Y > bannerSize.Y)
particle.Velocity = particle.Velocity with { Y = -particle.Velocity.Y };
}
var fadeIn = Math.Min(1f, (particle.MaxLife - particle.Life) / 20f);
var fadeOut = Math.Min(1f, particle.Life / 20f);
var lifeFade = Math.Min(fadeIn, fadeOut);
var edgeFadeX = Math.Min(
Math.Min(1f, (particle.Position.X + EdgeFadeDistance) / EdgeFadeDistance),
Math.Min(1f, (bannerSize.X - particle.Position.X + EdgeFadeDistance) / EdgeFadeDistance)
);
var edgeFadeY = Math.Min(
Math.Min(1f, (particle.Position.Y + EdgeFadeDistance) / EdgeFadeDistance),
Math.Min(1f, (bannerSize.Y - particle.Position.Y + EdgeFadeDistance) / EdgeFadeDistance)
);
var edgeFade = Math.Min(edgeFadeX, edgeFadeY);
var baseAlpha = lifeFade * edgeFade;
var finalAlpha = particle.Type == ParticleType.TwinklingStar
? baseAlpha * (0.6f + 0.4f * MathF.Sin(particle.Twinkle))
: baseAlpha;
if (particle.Type == ParticleType.ShootingStar && particle.Trail != null && particle.Trail.Count > 1)
{
var cyanColor = new Vector4(0.4f, 0.8f, 1.0f, 1.0f);
for (int t = 1; t < particle.Trail.Count; t++)
{
var trailProgress = (float)t / particle.Trail.Count;
var trailAlpha = Math.Min(1f, (1f - trailProgress) * finalAlpha * 1.8f);
var trailWidth = (1f - trailProgress) * 3f + 1f;
var glowAlpha = trailAlpha * 0.4f;
drawList.AddLine(
bannerStart + particle.Trail[t - 1],
bannerStart + particle.Trail[t],
ImGui.GetColorU32(cyanColor with { W = glowAlpha }),
trailWidth + 4f
);
drawList.AddLine(
bannerStart + particle.Trail[t - 1],
bannerStart + particle.Trail[t],
ImGui.GetColorU32(cyanColor with { W = trailAlpha }),
trailWidth
);
}
}
else if (particle.Type == ParticleType.TwinklingStar)
{
DrawTwinklingStar(drawList, screenPos, particle.Size, particle.Hue, finalAlpha, particle.Depth);
}
_particles[i] = particle;
}
}
private void DrawTwinklingStar(ImDrawListPtr drawList, Vector2 position, float size, float hue, float alpha,
float depth)
{
var color = HslToRgb(hue, 1.0f, 0.85f);
color.W = alpha;
drawList.AddCircleFilled(position, size, ImGui.GetColorU32(color));
var glowColor = color with { W = alpha * 0.3f };
drawList.AddCircleFilled(position, size * (1.2f + depth * 0.3f), ImGui.GetColorU32(glowColor));
}
private static Vector4 HslToRgb(float h, float s, float l)
{
h = h / 360f;
float c = (1 - MathF.Abs(2 * l - 1)) * s;
float x = c * (1 - MathF.Abs((h * 6) % 2 - 1));
float m = l - c / 2;
float r, g, b;
if (h < 1f / 6f)
{
r = c;
g = x;
b = 0;
}
else if (h < 2f / 6f)
{
r = x;
g = c;
b = 0;
}
else if (h < 3f / 6f)
{
r = 0;
g = c;
b = x;
}
else if (h < 4f / 6f)
{
r = 0;
g = x;
b = c;
}
else if (h < 5f / 6f)
{
r = x;
g = 0;
b = c;
}
else
{
r = c;
g = 0;
b = x;
}
return new Vector4(r + m, g + m, b + m, 1.0f);
}
private void SpawnParticle(Vector2 bannerSize)
{
var position = new Vector2(
(float)_random.NextDouble() * bannerSize.X,
(float)_random.NextDouble() * bannerSize.Y
);
var depthLayers = new[] { 0.5f, 1.0f, 1.5f };
var depth = depthLayers[_random.Next(depthLayers.Length)];
var velocity = new Vector2(
((float)_random.NextDouble() - 0.5f) * 0.05f * depth,
((float)_random.NextDouble() - 0.5f) * 0.05f * depth
);
var isBlue = _random.NextDouble() < 0.5;
var hue = isBlue ? 220f + (float)_random.NextDouble() * 30f : 270f + (float)_random.NextDouble() * 40f;
var size = (0.5f + (float)_random.NextDouble() * 2f) * depth;
var maxLife = 120f + (float)_random.NextDouble() * 60f;
_particles.Add(new Particle
{
Position = position,
Velocity = velocity,
Life = maxLife,
MaxLife = maxLife,
Size = size,
Type = ParticleType.TwinklingStar,
Trail = null,
Twinkle = (float)_random.NextDouble() * MathF.PI * 2,
Depth = depth,
Hue = hue
});
}
private void SpawnShootingStar(Vector2 bannerSize)
{
var maxLife = 80f + (float)_random.NextDouble() * 40f;
var startX = bannerSize.X * (0.3f + (float)_random.NextDouble() * 0.6f);
var startY = -10f;
_particles.Add(new Particle
{
Position = new Vector2(startX, startY),
Velocity = new Vector2(
-50f - (float)_random.NextDouble() * 40f,
30f + (float)_random.NextDouble() * 40f
),
Life = maxLife,
MaxLife = maxLife,
Size = 2.5f,
Type = ParticleType.ShootingStar,
Trail = new List<Vector2>(),
Twinkle = 0,
Depth = 1.0f,
Hue = 270f
});
}
private void DrawTabs()
{
using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 6f))
using (ImRaii.PushColor(ImGuiCol.Tab, UIColors.Get("ButtonDefault")))
using (ImRaii.PushColor(ImGuiCol.TabHovered, UIColors.Get("LightlessPurple")))
using (ImRaii.PushColor(ImGuiCol.TabActive, UIColors.Get("LightlessPurpleActive")))
{
using (var tabBar = ImRaii.TabBar("###ll_tabs", ImGuiTabBarFlags.None))
{
if (!tabBar)
return;
using (var changelogTab = ImRaii.TabItem("Changelog"))
{
if (changelogTab)
{
_selectedTab = 0;
DrawChangelog();
}
}
if (_credits.Credits != null && _credits.Credits.Count > 0)
{
using (var creditsTab = ImRaii.TabItem("Credits"))
{
if (creditsTab)
{
_selectedTab = 1;
DrawCredits();
}
}
}
}
}
}
private void DrawCredits()
{
using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f))
using (var child = ImRaii.Child("###ll_credits", new Vector2(0, ImGui.GetContentRegionAvail().Y - 60), false,
ImGuiWindowFlags.AlwaysVerticalScrollbar))
{
if (!child)
return;
ImGui.PushTextWrapPos();
if (_credits.Credits != null)
{
foreach (var category in _credits.Credits)
{
DrawCreditCategory(category);
ImGuiHelpers.ScaledDummy(10);
}
}
ImGui.PopTextWrapPos();
ImGui.Spacing();
}
}
private void DrawCreditCategory(CreditCategory category)
{
DrawFeatureSection(category.Category, UIColors.Get("LightlessBlue"));
foreach (var item in category.Items)
{
if (!string.IsNullOrEmpty(item.Role))
{
ImGui.BulletText($"{item.Name} — {item.Role}");
}
else
{
ImGui.BulletText(item.Name);
}
}
ImGuiHelpers.ScaledDummy(5);
}
private void DrawCloseButton()
{
ImGuiHelpers.ScaledDummy(5);
var closeWidth = 200f * ImGuiHelpers.GlobalScale;
var closeHeight = 35f * ImGuiHelpers.GlobalScale;
ImGui.SetCursorPosX((ImGui.GetWindowSize().X - closeWidth) / 2);
using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 8f))
using (ImRaii.PushColor(ImGuiCol.Button, UIColors.Get("LightlessPurple")))
using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurpleActive")))
using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("ButtonDefault")))
{
if (ImGui.Button("Got it!", new Vector2(closeWidth, closeHeight)))
{
// Update last seen version when user acknowledges the update notes
var ver = Assembly.GetExecutingAssembly().GetName().Version;
var currentVersion = ver == null ? string.Empty : $"{ver.Major}.{ver.Minor}.{ver.Build}";
_configService.Current.LastSeenVersion = currentVersion;
_configService.Save();
IsOpen = false;
}
}
}
private void DrawChangelog()
{
using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f))
using (var child = ImRaii.Child("###ll_changelog", new Vector2(0, ImGui.GetContentRegionAvail().Y - 60), false,
ImGuiWindowFlags.AlwaysVerticalScrollbar))
{
if (!child)
return;
if (_scrollToTop)
{
_scrollToTop = false;
ImGui.SetScrollHereY(0);
}
ImGui.PushTextWrapPos();
foreach (var entry in _changelog.Changelog)
{
DrawChangelogEntry(entry);
}
_hasInitializedCollapsingHeaders = true;
ImGui.PopTextWrapPos();
ImGui.Spacing();
}
}
private void DrawChangelogEntry(ChangelogEntry entry)
{
var isCurrent = entry.IsCurrent ?? false;
var currentColor = isCurrent
? UIColors.Get("LightlessGreen")
: new Vector4(0.95f, 0.95f, 1.0f, 1.0f);
var flags = isCurrent ? ImGuiTreeNodeFlags.DefaultOpen : ImGuiTreeNodeFlags.None;
if (!_hasInitializedCollapsingHeaders)
{
ImGui.SetNextItemOpen(isCurrent, ImGuiCond.Always);
}
bool isOpen;
using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 4f))
using (ImRaii.PushColor(ImGuiCol.Header, UIColors.Get("ButtonDefault")))
using (ImRaii.PushColor(ImGuiCol.Text, currentColor))
{
isOpen = ImGui.CollapsingHeader($" {entry.Name} — {entry.Date} ", flags);
ImGui.SameLine();
ImGui.TextColored(new Vector4(0.85f, 0.85f, 0.95f, 1.0f), $" — {entry.Tagline}");
}
if (!isOpen)
return;
ImGuiHelpers.ScaledDummy(8);
if (!string.IsNullOrEmpty(entry.Message))
{
ImGui.TextWrapped(entry.Message);
ImGuiHelpers.ScaledDummy(8);
return;
}
if (entry.Versions != null)
{
foreach (var version in entry.Versions)
{
DrawFeatureSection(version.Number, UIColors.Get("LightlessGreen"));
foreach (var item in version.Items)
{
ImGui.BulletText(item);
}
ImGuiHelpers.ScaledDummy(5);
}
}
}
private static void DrawFeatureSection(string title, Vector4 accentColor)
{
var drawList = ImGui.GetWindowDrawList();
var startPos = ImGui.GetCursorScreenPos();
var availableWidth = ImGui.GetContentRegionAvail().X;
var backgroundMin = startPos + new Vector2(-8, -4);
var backgroundMax = startPos + new Vector2(availableWidth + 8, 28);
var bgColor = new Vector4(0.12f, 0.12f, 0.15f, 0.7f);
drawList.AddRectFilled(backgroundMin, backgroundMax, ImGui.GetColorU32(bgColor), 6f);
drawList.AddRectFilled(
backgroundMin,
backgroundMin + new Vector2(4, backgroundMax.Y - backgroundMin.Y),
ImGui.GetColorU32(accentColor),
3f
);
var glowColor = accentColor with { W = 0.15f };
drawList.AddRect(
backgroundMin,
backgroundMax,
ImGui.GetColorU32(glowColor),
6f,
ImDrawFlags.None,
1.5f
);
// Calculate vertical centering
var textSize = ImGui.CalcTextSize(title);
var boxHeight = backgroundMax.Y - backgroundMin.Y;
var verticalOffset = (boxHeight - textSize.Y) / 5f;
ImGui.SetCursorPosX(ImGui.GetCursorPosX() + 8);
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + verticalOffset);
ImGui.TextColored(accentColor, title);
ImGui.SetCursorPosY(backgroundMax.Y - startPos.Y + ImGui.GetCursorPosY());
}
private void LoadEmbeddedResources()
{
try
{
var assembly = Assembly.GetExecutingAssembly();
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
// Load changelog
using var changelogStream = assembly.GetManifestResourceStream("LightlessSync.Changelog.changelog.yaml");
if (changelogStream != null)
{
using var reader = new StreamReader(changelogStream, Encoding.UTF8, true, 128);
var yaml = reader.ReadToEnd();
_changelog = deserializer.Deserialize<ChangelogFile>(yaml) ?? new();
}
// Load credits
using var creditsStream = assembly.GetManifestResourceStream("LightlessSync.Changelog.credits.yaml");
if (creditsStream != null)
{
using var reader = new StreamReader(creditsStream, Encoding.UTF8, true, 128);
var yaml = reader.ReadToEnd();
_credits = deserializer.Deserialize<CreditsFile>(yaml) ?? new();
}
}
catch
{
// Ignore - window will gracefully render with defaults
}
}
}

View File

@@ -84,7 +84,7 @@ public partial class ApiController
public async Task<UserProfileDto> UserGetProfile(UserDto dto)
{
if (!IsConnected) return new UserProfileDto(dto.User, Disabled: false, IsNSFW: null, ProfilePictureBase64: null, Description: null);
if (!IsConnected) return new UserProfileDto(dto.User, Disabled: false, IsNSFW: null, ProfilePictureBase64: null, Description: null, Tags: null);
return await _lightlessHub!.InvokeAsync<UserProfileDto>(nameof(UserGetProfile), dto).ConfigureAwait(false);
}

View File

@@ -195,7 +195,14 @@ public partial class ApiController
public Task Client_UserUpdateProfile(UserDto dto)
{
Logger.LogDebug("Client_UserUpdateProfile: {dto}", dto);
ExecuteSafely(() => Mediator.Publish(new ClearProfileDataMessage(dto.User)));
ExecuteSafely(() => Mediator.Publish(new ClearProfileUserDataMessage(dto.User)));
return Task.CompletedTask;
}
public Task Client_GroupSendProfile(GroupProfileDto groupInfo)
{
Logger.LogDebug("Client_GroupSendProfile: {dto}", groupInfo);
ExecuteSafely(() => Mediator.Publish(new ClearProfileGroupDataMessage(groupInfo.Group)));
return Task.CompletedTask;
}
@@ -380,6 +387,12 @@ public partial class ApiController
_lightlessHub!.On(nameof(Client_UserUpdateProfile), act);
}
public void ClientGroupSendProfile(Action<GroupProfileDto> act)
{
if (_initialized) return;
_lightlessHub!.On(nameof(Client_GroupSendProfile), act);
}
public void OnUserUpdateSelfPairPermissions(Action<UserPermissionsDto> act)
{
if (_initialized) return;

View File

@@ -115,6 +115,18 @@ public partial class ApiController
CheckConnection();
return await _lightlessHub!.InvokeAsync<int>(nameof(GroupPrune), group, days, execute).ConfigureAwait(false);
}
public async Task<GroupProfileDto> GroupGetProfile(GroupDto dto)
{
CheckConnection();
if (!IsConnected) return new GroupProfileDto(Group: dto.Group, Description: null, Tags: null, PictureBase64: null, IsNsfw: false, IsDisabled: false);
return await _lightlessHub!.InvokeAsync<GroupProfileDto>(nameof(GroupGetProfile), dto).ConfigureAwait(false);
}
public async Task GroupSetProfile(GroupProfileDto dto)
{
CheckConnection();
await _lightlessHub!.InvokeAsync(nameof(GroupSetProfile), dto).ConfigureAwait(false);
}
public async Task<List<GroupFullInfoDto>> GroupsGetAll()
{
@@ -139,7 +151,6 @@ public partial class ApiController
.ConfigureAwait(false);
}
private void CheckConnection()
{
if (ServerState is not (ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting)) throw new InvalidDataException("Not connected");

View File

@@ -608,17 +608,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
ServerState = state;
}
public Task Client_GroupSendProfile(GroupProfileDto groupInfo)
{
throw new NotImplementedException();
}
public Task<GroupProfileDto> GroupGetProfile(GroupDto dto)
{
throw new NotImplementedException();
}
public Task GroupSetProfile(GroupProfileDto dto)
public Task<UserProfileDto?> UserGetLightfinderProfile(string hashedCid)
{
throw new NotImplementedException();
}

View File

@@ -133,6 +133,12 @@
"Microsoft.IdentityModel.Tokens": "8.7.0"
}
},
"YamlDotNet": {
"type": "Direct",
"requested": "[16.3.0, )",
"resolved": "16.3.0",
"contentHash": "SgMOdxbz8X65z8hraIs6hOEdnkH6hESTAIUa7viEngHOYaH+6q5XJmwr1+yb9vJpNQ19hCQY69xbFsLtXpobQA=="
},
"K4os.Compression.LZ4": {
"type": "Transitive",
"resolved": "1.3.8",