diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 425703a..606a23d 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -459,15 +459,6 @@ public sealed class Plugin : IDalamudPlugin sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); - - services.AddScoped(sp => new SyncshellFinderUI( - sp.GetRequiredService>(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), diff --git a/LightlessSync/Services/Mediator/LightlessMediator.cs b/LightlessSync/Services/Mediator/LightlessMediator.cs index 52399e2..87e36ad 100644 --- a/LightlessSync/Services/Mediator/LightlessMediator.cs +++ b/LightlessSync/Services/Mediator/LightlessMediator.cs @@ -63,23 +63,31 @@ public sealed class LightlessMediator : IHostedService _ = Task.Run(async () => { - while (!_loopCts.Token.IsCancellationRequested) + try { - while (!_processQueue) + while (!_loopCts.Token.IsCancellationRequested) { + while (!_processQueue) + { + await Task.Delay(100, _loopCts.Token).ConfigureAwait(false); + } + await Task.Delay(100, _loopCts.Token).ConfigureAwait(false); + + HashSet processedMessages = []; + while (_messageQueue.TryDequeue(out var message)) + { + if (processedMessages.Contains(message)) { continue; } + + processedMessages.Add(message); + + ExecuteMessage(message); + } } - - await Task.Delay(100, _loopCts.Token).ConfigureAwait(false); - - HashSet processedMessages = []; - while (_messageQueue.TryDequeue(out var message)) - { - if (processedMessages.Contains(message)) { continue; } - processedMessages.Add(message); - - ExecuteMessage(message); - } + } + catch (OperationCanceledException) + { + _logger.LogInformation("LightlessMediator stopped"); } }); diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index e297e11..be56434 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -34,45 +34,65 @@ namespace LightlessSync.UI; public class CompactUi : WindowMediatorSubscriberBase { - private readonly CharacterAnalyzer _characterAnalyzer; + #region Constants + + private const float ConnectButtonHighlightThickness = 14f; + + #endregion + + #region Services + private readonly ApiController _apiController; + private readonly CharacterAnalyzer _characterAnalyzer; + private readonly DalamudUtilService _dalamudUtilService; + private readonly DrawEntityFactory _drawEntityFactory; + private readonly FileUploadManager _fileTransferManager; + private readonly IpcManager _ipcManager; + private readonly LightFinderService _broadcastService; private readonly LightlessConfigService _configService; private readonly LightlessMediator _lightlessMediator; private readonly PairLedger _pairLedger; - private readonly ConcurrentDictionary> _currentDownloads = new(); - private readonly DrawEntityFactory _drawEntityFactory; - private readonly FileUploadManager _fileTransferManager; - private readonly PlayerPerformanceConfigService _playerPerformanceConfig; private readonly PairUiService _pairUiService; - private readonly SelectTagForPairUi _selectTagForPairUi; - private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi; - private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi; - private readonly RenameSyncshellTagUi _renameSyncshellTagUi; - private readonly SelectPairForTagUi _selectPairsForGroupUi; - private readonly RenamePairTagUi _renamePairTagUi; - private readonly IpcManager _ipcManager; + private readonly PlayerPerformanceConfigService _playerPerformanceConfig; private readonly ServerConfigurationManager _serverManager; - private readonly TopTabMenu _tabMenu; private readonly TagHandler _tagHandler; private readonly UiSharedService _uiSharedService; - private readonly LightFinderService _broadcastService; - private readonly DalamudUtilService _dalamudUtilService; - + + #endregion + + #region UI Components + + private readonly AnimatedHeader _animatedHeader = new(); + private readonly RenamePairTagUi _renamePairTagUi; + private readonly RenameSyncshellTagUi _renameSyncshellTagUi; + private readonly SelectPairForTagUi _selectPairsForGroupUi; + private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi; + private readonly SelectTagForPairUi _selectTagForPairUi; + private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi; + private readonly SeluneBrush _seluneBrush = new(); + private readonly TopTabMenu _tabMenu; + + #endregion + + #region State + + private readonly ConcurrentDictionary> _currentDownloads = new(); private List _drawFolders; + private Pair? _focusedPair; private Pair? _lastAddedUser; private string _lastAddedUserComment = string.Empty; private Vector2 _lastPosition = Vector2.One; private Vector2 _lastSize = Vector2.One; + private int _pendingFocusFrame = -1; + private Pair? _pendingFocusPair; private bool _showModalForUserAddition; private float _transferPartHeight; private bool _wasOpen; private float _windowContentWidth; - private readonly SeluneBrush _seluneBrush = new(); - private readonly AnimatedHeader _animatedHeader = new(); - private const float _connectButtonHighlightThickness = 14f; - private Pair? _focusedPair; - private Pair? _pendingFocusPair; - private int _pendingFocusFrame = -1; + + #endregion + + #region Constructor public CompactUi( ILogger logger, @@ -156,6 +176,10 @@ public class CompactUi : WindowMediatorSubscriberBase _lightlessMediator = mediator; } + #endregion + + #region Lifecycle + public override void OnClose() { ForceReleaseFocus(); @@ -297,6 +321,10 @@ public class CompactUi : WindowMediatorSubscriberBase } } + #endregion + + #region Content Drawing + private void DrawPairs() { float ySize = Math.Abs(_transferPartHeight) < 0.0001f @@ -410,11 +438,9 @@ public class CompactUi : WindowMediatorSubscriberBase return new DownloadSummary(totalFiles, transferredFiles, transferredBytes, totalBytes); } - [StructLayout(LayoutKind.Auto)] - private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes) - { - public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0; - } + #endregion + + #region Header Drawing private void DrawUIDHeader() { @@ -626,6 +652,8 @@ public class CompactUi : WindowMediatorSubscriberBase clipPadding: padding, highlightColorOverride: vanityGlowColor, highlightAlphaOverride: 0.05f); + + ImGui.SetTooltip("Click to copy"); } if (headerItemClicked) @@ -633,8 +661,6 @@ public class CompactUi : WindowMediatorSubscriberBase ImGui.SetClipboardText(uidText); } - UiSharedService.AttachToolTip("Click to copy"); - // Connect/Disconnect button next to big UID (use screen pos to avoid affecting layout) DrawConnectButton(uidTextRectMin.Y + textVerticalOffset, uidTextSize.Y); @@ -756,7 +782,7 @@ public class CompactUi : WindowMediatorSubscriberBase ImGui.GetItemRectMax(), SeluneHighlightMode.Both, borderOnly: true, - borderThicknessOverride: _connectButtonHighlightThickness, + borderThicknessOverride: ConnectButtonHighlightThickness, exactSize: true, clipToElement: true, roundingOverride: ImGui.GetStyle().FrameRounding); @@ -766,6 +792,10 @@ public class CompactUi : WindowMediatorSubscriberBase } } + #endregion + + #region Folder Building + private IEnumerable DrawFolders { get @@ -901,6 +931,10 @@ public class CompactUi : WindowMediatorSubscriberBase } } + #endregion + + #region Filtering & Sorting + private static bool PassesFilter(PairUiEntry entry, string filter) { if (string.IsNullOrEmpty(filter)) return true; @@ -1044,10 +1078,11 @@ public class CompactUi : WindowMediatorSubscriberBase return SortGroupEntries(entries, group); } - private void UiSharedService_GposeEnd() - { - IsOpen = _wasOpen; - } + #endregion + + #region GPose Handlers + + private void UiSharedService_GposeEnd() => IsOpen = _wasOpen; private void UiSharedService_GposeStart() { @@ -1055,6 +1090,10 @@ public class CompactUi : WindowMediatorSubscriberBase IsOpen = false; } + #endregion + + #region Focus Tracking + private void RegisterFocusCharacter(Pair pair) { _pendingFocusPair = pair; @@ -1100,4 +1139,16 @@ public class CompactUi : WindowMediatorSubscriberBase _pendingFocusPair = null; _pendingFocusFrame = -1; } + + #endregion + + #region Helper Types + + [StructLayout(LayoutKind.Auto)] + private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes) + { + public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0; + } + + #endregion } diff --git a/LightlessSync/UI/LightFinderUI.cs b/LightlessSync/UI/LightFinderUI.cs index 22911cb..b6bfc73 100644 --- a/LightlessSync/UI/LightFinderUI.cs +++ b/LightlessSync/UI/LightFinderUI.cs @@ -1,445 +1,1188 @@ 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 Dalamud.Utility; +using LightlessSync.API.Data; +using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; +using LightlessSync.API.Dto; using LightlessSync.API.Dto.Group; using LightlessSync.LightlessConfiguration; using LightlessSync.Services; using LightlessSync.Services.LightFinder; using LightlessSync.Services.Mediator; +using LightlessSync.UI.Services; +using LightlessSync.UI.Style; +using LightlessSync.UI.Tags; using LightlessSync.Utils; using LightlessSync.WebAPI; using Microsoft.Extensions.Logging; using System.Numerics; -namespace LightlessSync.UI +namespace LightlessSync.UI; + +public class LightFinderUI : WindowMediatorSubscriberBase { - public class LightFinderUI : WindowMediatorSubscriberBase + #region Services + + private readonly ApiController _apiController; + private readonly DalamudUtilService _dalamudUtilService; + private readonly LightFinderScannerService _broadcastScannerService; + private readonly LightFinderService _broadcastService; + private readonly LightlessConfigService _configService; + private readonly LightlessProfileManager _lightlessProfileManager; + private readonly PairUiService _pairUiService; + private readonly UiSharedService _uiSharedService; + + #endregion + + #region UI Components + + private readonly AnimatedHeader _animatedHeader = new(); + private readonly List _seResolvedSegments = new(); + private readonly Vector4 _tagBackgroundColor = new(0.18f, 0.18f, 0.18f, 0.95f); + private readonly Vector4 _tagBorderColor = new(0.35f, 0.35f, 0.35f, 0.4f); + + #endregion + + #region State + + private IReadOnlyList _allSyncshells = Array.Empty(); + private bool _compactView; + private List _currentSyncshells = []; + private GroupJoinDto? _joinDto; + private GroupJoinInfoDto? _joinInfo; + private readonly List _nearbySyncshells = []; + private DefaultPermissionsDto _ownPermissions = null!; + private readonly HashSet _recentlyJoined = new(StringComparer.Ordinal); + private int _selectedNearbyIndex = -1; + private readonly List<(string Label, string? GID, bool IsAvailable)> _syncshellOptions = new(); + private LightfinderTab _selectedTab = LightfinderTab.NearbySyncshells; + private string _userUid = string.Empty; + + private enum LightfinderTab { NearbySyncshells, BroadcastSettings, Help } + +#if DEBUG + private enum LightfinderTabDebug { NearbySyncshells, BroadcastSettings, Help, Debug } + private LightfinderTabDebug _selectedTabDebug = LightfinderTabDebug.NearbySyncshells; +#endif + +#if DEBUG + private bool _useTestSyncshells; +#endif + + #endregion + + #region Constructor + + public LightFinderUI( + ILogger logger, + LightlessMediator mediator, + PerformanceCollectorService performanceCollectorService, + LightFinderService broadcastService, + LightlessConfigService configService, + UiSharedService uiShared, + ApiController apiController, + LightFinderScannerService broadcastScannerService, + PairUiService pairUiService, + DalamudUtilService dalamudUtilService, + LightlessProfileManager lightlessProfileManager + ) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService) { - private readonly ApiController _apiController; - private readonly LightlessConfigService _configService; - private readonly LightFinderService _broadcastService; - private readonly UiSharedService _uiSharedService; - private readonly LightFinderScannerService _broadcastScannerService; + _broadcastService = broadcastService; + _uiSharedService = uiShared; + _configService = configService; + _apiController = apiController; + _broadcastScannerService = broadcastScannerService; + _pairUiService = pairUiService; + _dalamudUtilService = dalamudUtilService; + _lightlessProfileManager = lightlessProfileManager; - private IReadOnlyList _allSyncshells = Array.Empty(); - private string _userUid = string.Empty; + _animatedHeader.Height = 100f; + _animatedHeader.EnableBottomGradient = true; + _animatedHeader.GradientHeight = 120f; + _animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects; - private readonly List<(string Label, string? GID, bool IsAvailable)> _syncshellOptions = new(); + IsOpen = false; + WindowBuilder.For(this) + .SetSizeConstraints(new Vector2(620, 85), new Vector2(700, 600)) + .Apply(); - public LightFinderUI( - ILogger logger, - LightlessMediator mediator, - PerformanceCollectorService performanceCollectorService, - LightFinderService broadcastService, - LightlessConfigService configService, - UiSharedService uiShared, - ApiController apiController, - LightFinderScannerService broadcastScannerService - ) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService) + Mediator.Subscribe(this, async _ => await RefreshNearbySyncshellsAsync().ConfigureAwait(false)); + Mediator.Subscribe(this, async _ => await RefreshNearbySyncshellsAsync().ConfigureAwait(false)); + Mediator.Subscribe(this, async _ => await RefreshNearbySyncshellsAsync(_.gid).ConfigureAwait(false)); + Mediator.Subscribe(this, async _ => await RefreshNearbySyncshellsAsync(_.gid).ConfigureAwait(false)); + } + + #endregion + + #region Lifecycle + + public override void OnOpen() + { + _userUid = _apiController.UID; + _ownPermissions = _apiController.DefaultPermissions.DeepClone()!; + _ = RefreshSyncshellsAsync(); + _ = RefreshNearbySyncshellsAsync(); + } + + public override void OnClose() + { + _animatedHeader.ClearParticles(); + ClearSelection(); + base.OnClose(); + } + + #endregion + + #region Main Drawing + + protected override void DrawInternal() + { + var contentWidth = ImGui.GetContentRegionAvail().X; + _animatedHeader.Draw(contentWidth, (_, _) => { }); + + if (!_broadcastService.IsLightFinderAvailable) { - _broadcastService = broadcastService; - _uiSharedService = uiShared; - _configService = configService; - _apiController = apiController; - _broadcastScannerService = broadcastScannerService; - - IsOpen = false; - WindowBuilder.For(this) - .SetSizeConstraints(new Vector2(600, 465), new Vector2(750, 525)) - .Apply(); + ImGui.TextColored(UIColors.Get("LightlessYellow"), "This server doesn't support Lightfinder."); + ImGuiHelpers.ScaledDummy(2f); } - private void RebuildSyncshellDropdownOptions() + DrawStatusPanel(); + ImGuiHelpers.ScaledDummy(4f); + +#if DEBUG + var debugTabOptions = new List> { - var selectedGid = _configService.Current.SelectedFinderSyncshell; - var allSyncshells = _allSyncshells ?? []; - var filteredSyncshells = allSyncshells - .Where(g => string.Equals(g.OwnerUID, _userUid, StringComparison.Ordinal) || g.GroupUserInfo.IsModerator()) - .ToList(); + new("Nearby Syncshells", LightfinderTabDebug.NearbySyncshells), + new("Broadcast", LightfinderTabDebug.BroadcastSettings), + new("Help", LightfinderTabDebug.Help), + new("Debug", LightfinderTabDebug.Debug) + }; + UiSharedService.Tab("LightfinderTabs", debugTabOptions, ref _selectedTabDebug); + + ImGuiHelpers.ScaledDummy(4f); + + switch (_selectedTabDebug) + { + case LightfinderTabDebug.NearbySyncshells: + DrawNearbySyncshellsTab(); + break; + case LightfinderTabDebug.BroadcastSettings: + DrawBroadcastSettingsTab(); + break; + case LightfinderTabDebug.Help: + DrawHelpTab(); + break; + case LightfinderTabDebug.Debug: + DrawDebugTab(); + break; + } +#else + var tabOptions = new List> + { + new("Nearby Syncshells", LightfinderTab.NearbySyncshells), + new("Broadcast", LightfinderTab.BroadcastSettings), + new("Help", LightfinderTab.Help) + }; + UiSharedService.Tab("LightfinderTabs", tabOptions, ref _selectedTab); + + ImGuiHelpers.ScaledDummy(4f); + + switch (_selectedTab) + { + case LightfinderTab.NearbySyncshells: + DrawNearbySyncshellsTab(); + break; + case LightfinderTab.BroadcastSettings: + DrawBroadcastSettingsTab(); + break; + case LightfinderTab.Help: + DrawHelpTab(); + break; + } +#endif - _syncshellOptions.Clear(); - _syncshellOptions.Add(("None", null, true)); + if (_joinDto != null && _joinInfo != null && _joinInfo.Success) + DrawJoinConfirmation(); + } - var addedGids = new HashSet(StringComparer.Ordinal); + private void DrawStatusPanel() + { + var scale = ImGuiHelpers.GlobalScale; + var isBroadcasting = _broadcastService.IsBroadcasting; + var cooldown = _broadcastService.RemainingCooldown; + var isOnCooldown = cooldown.HasValue && cooldown.Value.TotalSeconds > 0; - foreach (var shell in filteredSyncshells) + var accent = isBroadcasting ? UIColors.Get("LightlessGreen") : UIColors.Get("LightlessPurple"); + var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.16f); + var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.32f); + var infoColor = ImGuiColors.DalamudGrey; + + var summaryHeight = MathF.Max(ImGui.GetTextLineHeightWithSpacing() * 2.4f, 46f * scale); + float buttonWidth = 130 * scale; + + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize))) + using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(12f * scale, 6f * scale))) + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(18f * scale, 4f * scale))) + using (ImRaii.PushColor(ImGuiCol.ChildBg, UiSharedService.Color(accentBg))) + using (ImRaii.PushColor(ImGuiCol.Border, UiSharedService.Color(accentBorder))) + using (var child = ImRaii.Child("StatusPanel", new Vector2(-1f, summaryHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) + { + if (child) { - var label = shell.GroupAliasOrGID ?? shell.GID; - _syncshellOptions.Add((label, shell.GID, true)); - addedGids.Add(shell.GID); - } - - if (!string.IsNullOrEmpty(selectedGid) && !addedGids.Contains(selectedGid)) - { - var matching = allSyncshells.FirstOrDefault(g => string.Equals(g.GID, selectedGid, StringComparison.Ordinal)); - if (matching != null) + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(8f * scale, 4f * scale))) { - var label = matching.GroupAliasOrGID ?? matching.GID; - _syncshellOptions.Add((label, matching.GID, true)); - addedGids.Add(matching.GID); + if (ImGui.BeginTable("StatusPanelTable", 6, ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.NoBordersInBody)) + { + ImGui.TableSetupColumn("Status", ImGuiTableColumnFlags.WidthStretch, 1f); + ImGui.TableSetupColumn("Time", ImGuiTableColumnFlags.WidthStretch, 1f); + ImGui.TableSetupColumn("NearbyPlayers", ImGuiTableColumnFlags.WidthStretch, 1f); + ImGui.TableSetupColumn("NearbySyncshells", ImGuiTableColumnFlags.WidthStretch, 1f); + ImGui.TableSetupColumn("Broadcasting", ImGuiTableColumnFlags.WidthStretch, 1f); + ImGui.TableSetupColumn("Button", ImGuiTableColumnFlags.WidthFixed, buttonWidth + 16f * scale); + + ImGui.TableNextRow(); + + // Status cell + var statusColor = isBroadcasting ? UIColors.Get("LightlessGreen") : UIColors.Get("DimRed"); + var statusText = isBroadcasting ? "Active" : "Inactive"; + var statusIcon = isBroadcasting ? FontAwesomeIcon.CheckCircle : FontAwesomeIcon.TimesCircle; + DrawStatusCell(statusIcon, statusColor, statusText, "Status", infoColor, scale); + + // Time remaining Cooldown cell + string timeValue; + string timeSub; + Vector4 timeColor; + if (isOnCooldown) + { + timeValue = $"{Math.Ceiling(cooldown!.Value.TotalSeconds)}s"; + timeSub = "Cooldown"; + timeColor = UIColors.Get("DimRed"); + } + else if (isBroadcasting && _broadcastService.RemainingTtl is { } remaining && remaining > TimeSpan.Zero) + { + timeValue = $"{remaining:hh\\:mm\\:ss}"; + timeSub = "Time left"; + timeColor = UIColors.Get("LightlessYellow"); + } + else + { + timeValue = "--:--:--"; + timeSub = "Time left"; + timeColor = infoColor; + } + DrawStatusCell(FontAwesomeIcon.Clock, timeColor, timeValue, timeSub, infoColor, scale); + + // Nearby players cell + var nearbyPlayerCount = _broadcastScannerService.CountActiveBroadcasts(); + var nearbyPlayerColor = nearbyPlayerCount > 0 ? UIColors.Get("LightlessBlue") : infoColor; + DrawStatusCell(FontAwesomeIcon.Users, nearbyPlayerColor, nearbyPlayerCount.ToString(), "Players", infoColor, scale); + + // Nearby syncshells cell + var nearbySyncshellCount = _nearbySyncshells.Count; + var nearbySyncshellColor = nearbySyncshellCount > 0 ? UIColors.Get("LightlessPurple") : infoColor; + DrawStatusCell(FontAwesomeIcon.Compass, nearbySyncshellColor, nearbySyncshellCount.ToString(), "Syncshells", infoColor, scale); + + // Broadcasting syncshell cell + var isBroadcastingSyncshell = _configService.Current.SyncshellFinderEnabled && isBroadcasting; + var broadcastSyncshellColor = isBroadcastingSyncshell ? UIColors.Get("LightlessGreen") : infoColor; + var broadcastSyncshellText = isBroadcastingSyncshell ? "Yes" : "No"; + var broadcastSyncshellIcon = FontAwesomeIcon.Wifi; + DrawStatusCell(broadcastSyncshellIcon, broadcastSyncshellColor, broadcastSyncshellText, "Broadcasting", infoColor, scale); + + // Enable/Disable button cell - right aligned + ImGui.TableNextColumn(); + + float cellWidth = ImGui.GetContentRegionAvail().X; + float offsetX = cellWidth - buttonWidth; + if (offsetX > 0) + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + offsetX); + + using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 6f * scale)) + { + Vector4 buttonColor; + if (isOnCooldown) + buttonColor = UIColors.Get("DimRed"); + else if (isBroadcasting) + buttonColor = UIColors.Get("LightlessGreen"); + else + buttonColor = UIColors.Get("LightlessPurple"); + + using (ImRaii.PushColor(ImGuiCol.Button, buttonColor)) + using (ImRaii.PushColor(ImGuiCol.ButtonHovered, buttonColor.WithAlpha(0.85f))) + using (ImRaii.PushColor(ImGuiCol.ButtonActive, buttonColor.WithAlpha(0.75f))) + using (ImRaii.Disabled(isOnCooldown || !_broadcastService.IsLightFinderAvailable)) + { + string buttonText = isBroadcasting ? "Disable" : "Enable"; + if (ImGui.Button(buttonText, new Vector2(buttonWidth, 0))) + _broadcastService.ToggleBroadcast(); + } + } + + ImGui.EndTable(); + } } } + } + } - if (!string.IsNullOrEmpty(selectedGid) && !addedGids.Contains(selectedGid)) + private void DrawStatusCell(FontAwesomeIcon icon, Vector4 iconColor, string mainText, string subText, Vector4 subColor, float scale) + { + ImGui.TableNextColumn(); + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(6f * scale, 2f * scale))) + using (ImRaii.Group()) + { + _uiSharedService.IconText(icon, iconColor); + ImGui.SameLine(0f, 6f * scale); + using (ImRaii.PushColor(ImGuiCol.Text, iconColor)) { - _syncshellOptions.Add(($"[Unavailable] {selectedGid}", selectedGid, false)); + ImGui.TextUnformatted(mainText); + } + using (ImRaii.PushColor(ImGuiCol.Text, subColor)) + { + ImGui.TextUnformatted(subText); + } + } + } + + #endregion + + #region Nearby Syncshells Tab + + private void DrawNearbySyncshellsTab() + { + ImGui.BeginGroup(); + +#if DEBUG + if (ImGui.SmallButton("Test Data")) + { + _useTestSyncshells = !_useTestSyncshells; + _ = Task.Run(async () => await RefreshNearbySyncshellsAsync().ConfigureAwait(false)); + } + ImGui.SameLine(); +#endif + + string checkboxLabel = "Compact"; + float checkboxWidth = ImGui.CalcTextSize(checkboxLabel).X + ImGui.GetFrameHeight() + 8f; + float availWidth = ImGui.GetContentRegionAvail().X; + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + availWidth - checkboxWidth); + ImGui.Checkbox(checkboxLabel, ref _compactView); + ImGui.EndGroup(); + + ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); + + if (_nearbySyncshells.Count == 0) + { + DrawNoSyncshellsMessage(); + return; + } + + var cardData = BuildSyncshellCardData(); + if (cardData.Count == 0) + { + ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells found."); + return; + } + + if (_compactView) + DrawSyncshellGrid(cardData); + else + DrawSyncshellList(cardData); + } + + private void DrawNoSyncshellsMessage() + { + ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells are being broadcasted."); + + if (!_broadcastService.IsBroadcasting) + { + ImGuiHelpers.ScaledDummy(4f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow")); + ImGuiHelpers.ScaledDummy(2f); + + ImGui.TextColored(UIColors.Get("LightlessYellow"), "Lightfinder must be active to find nearby syncshells."); + } + } + + private List<(GroupJoinDto Shell, string BroadcasterName)> BuildSyncshellCardData() + { + string? myHashedCid = null; + try + { + var cid = _dalamudUtilService.GetCID(); + myHashedCid = cid.ToString().GetHash256(); + } + catch { } + + var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts() + .Where(b => !string.Equals(b.HashedCID, myHashedCid, StringComparison.Ordinal)) + .ToList(); + + var cardData = new List<(GroupJoinDto Shell, string BroadcasterName)>(); + + foreach (var shell in _nearbySyncshells) + { + if (shell?.Group == null || string.IsNullOrEmpty(shell.Group.GID)) + continue; + +#if DEBUG + if (_useTestSyncshells) + { + var displayName = !string.IsNullOrEmpty(shell.Group.Alias) ? shell.Group.Alias : shell.Group.GID; + cardData.Add((shell, $"{displayName} (Test World)")); + continue; + } +#endif + + var broadcast = broadcasts.FirstOrDefault(b => string.Equals(b.GID, shell.Group.GID, StringComparison.Ordinal)); + if (broadcast == null) + continue; + + var (name, address) = _dalamudUtilService.FindPlayerByNameHash(broadcast.HashedCID); + if (string.IsNullOrEmpty(name)) + continue; + + var worldName = _dalamudUtilService.GetWorldNameFromPlayerAddress(address); + var broadcasterName = !string.IsNullOrEmpty(worldName) ? $"{name} ({worldName})" : name; + + cardData.Add((shell, broadcasterName)); + } + + return cardData; + } + + private void DrawSyncshellList(List<(GroupJoinDto Shell, string BroadcasterName)> listData) + { + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8f); + ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1f); + + if (ImGui.BeginChild("SyncshellListScroll", new Vector2(-1, -1), border: false)) + { + foreach (var (shell, broadcasterName) in listData) + { + DrawSyncshellListItem(shell, broadcasterName); + ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); + } + } + ImGui.EndChild(); + + ImGui.PopStyleVar(2); + } + + private void DrawSyncshellListItem(GroupJoinDto shell, string broadcasterName) + { + ImGui.PushID(shell.Group.GID); + float rowHeight = 74f * ImGuiHelpers.GlobalScale; + + ImGui.BeginChild($"ShellRow##{shell.Group.GID}", new Vector2(-1, rowHeight), border: true); + + var displayName = !string.IsNullOrEmpty(shell.Group.Alias) ? shell.Group.Alias : shell.Group.GID; + var style = ImGui.GetStyle(); + float startX = ImGui.GetCursorPosX(); + float regionW = ImGui.GetContentRegionAvail().X; + float rightTxtW = ImGui.CalcTextSize(broadcasterName).X; + + _uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple")); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Click to open profile."); + if (ImGui.IsItemClicked()) + Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group)); + + float rightX = startX + regionW - rightTxtW - style.ItemSpacing.X; + ImGui.SameLine(); + ImGui.SetCursorPosX(rightX); + ImGui.TextUnformatted(broadcasterName); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Broadcaster"); + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); + + var groupProfile = _lightlessProfileManager.GetLightlessGroupProfile(shell.Group); + IReadOnlyList groupTags = groupProfile?.Tags.Count > 0 + ? ProfileTagService.ResolveTags(groupProfile.Tags) + : []; + + var limitedTags = groupTags.Count > 3 ? [.. groupTags.Take(3)] : groupTags; + float tagScale = ImGuiHelpers.GlobalScale * 0.9f; + + Vector2 rowStartLocal = ImGui.GetCursorPos(); + float tagsWidth = 0f; + + if (limitedTags.Count > 0) + (tagsWidth, _) = RenderProfileTagsSingleRow(limitedTags, tagScale); + else + { + ImGui.SetCursorPosX(startX); + ImGui.TextDisabled("No tags"); + ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); + } + + float joinX = rowStartLocal.X + (tagsWidth > 0 ? tagsWidth + style.ItemSpacing.X : 0f); + ImGui.SetCursorPos(new Vector2(joinX, rowStartLocal.Y)); + DrawJoinButton(shell, false); + + ImGui.EndChild(); + ImGui.PopID(); + } + + private void DrawSyncshellGrid(List<(GroupJoinDto Shell, string BroadcasterName)> cardData) + { + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8f); + ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1f); + + foreach (var (shell, _) in cardData) + { + DrawSyncshellCompactItem(shell); + ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); + } + + ImGui.PopStyleVar(2); + } + + private void DrawSyncshellCompactItem(GroupJoinDto shell) + { + ImGui.PushID(shell.Group.GID); + float rowHeight = 36f * ImGuiHelpers.GlobalScale; + + ImGui.BeginChild($"ShellCompact##{shell.Group.GID}", new Vector2(-1, rowHeight), border: true); + + var displayName = !string.IsNullOrEmpty(shell.Group.Alias) ? shell.Group.Alias : shell.Group.GID; + var style = ImGui.GetStyle(); + float availW = ImGui.GetContentRegionAvail().X; + + ImGui.AlignTextToFramePadding(); + _uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple")); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Click to open profile."); + if (ImGui.IsItemClicked()) + Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group)); + + ImGui.SameLine(); + DrawJoinButton(shell, false); + + ImGui.EndChild(); + ImGui.PopID(); + } + + private void DrawJoinButton(GroupJoinDto shell, bool fullWidth) + { + const string visibleLabel = "Join"; + var label = $"{visibleLabel}##{shell.Group.GID}"; + + var isAlreadyMember = _currentSyncshells.Exists(g => string.Equals(g.GID, shell.Group.GID, StringComparison.Ordinal)); + var isRecentlyJoined = _recentlyJoined.Contains(shell.Group.GID); + var isOwnBroadcast = _configService.Current.SyncshellFinderEnabled + && _broadcastService.IsBroadcasting + && string.Equals(_configService.Current.SelectedFinderSyncshell, shell.Group.GID, StringComparison.Ordinal); + + Vector2 buttonSize; + if (fullWidth) + { + buttonSize = new Vector2(-1, 0); + } + else + { + var textSize = ImGui.CalcTextSize(visibleLabel); + var width = textSize.X + ImGui.GetStyle().FramePadding.X * 20f; + buttonSize = new Vector2(width, 30f); + + float availX = ImGui.GetContentRegionAvail().X; + float curX = ImGui.GetCursorPosX(); + ImGui.SetCursorPosX(curX + availX - buttonSize.X); + } + + if (isOwnBroadcast) + { + ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessPurple")); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurple").WithAlpha(0.85f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessPurple").WithAlpha(0.75f)); + + using (ImRaii.Disabled()) + ImGui.Button(label, buttonSize); + + UiSharedService.AttachToolTip("You can't join your own Syncshell, silly! That's like trying to high-five yourself."); + } + else if (!isAlreadyMember && !isRecentlyJoined) + { + ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessGreen")); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreen").WithAlpha(0.75f)); + + if (ImGui.Button(label, buttonSize)) + { + _ = Task.Run(async () => + { + try + { + var info = await _apiController.GroupJoinHashed(new GroupJoinHashedDto( + shell.Group, shell.Password, shell.GroupUserPreferredPermissions + )).ConfigureAwait(false); + + if (info?.Success == true) + { + _joinDto = new GroupJoinDto(shell.Group, shell.Password, shell.GroupUserPreferredPermissions); + _joinInfo = info; + _ownPermissions = _apiController.DefaultPermissions.DeepClone()!; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Join failed for {GID}", shell.Group.GID); + } + }); + } + } + else + { + ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("DimRed")); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("DimRed").WithAlpha(0.85f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("DimRed").WithAlpha(0.75f)); + + using (ImRaii.Disabled()) + ImGui.Button(label, buttonSize); + + UiSharedService.AttachToolTip("Already a member of this Syncshell."); + } + + ImGui.PopStyleColor(3); + } + + + private void DrawJoinConfirmation() + { + if (_joinDto == null || _joinInfo == null) return; + + ImGui.Separator(); + ImGui.TextUnformatted($"Join Syncshell: {_joinDto.Group.AliasOrGID} by {_joinInfo.OwnerAliasOrUID}"); + ImGuiHelpers.ScaledDummy(2f); + ImGui.TextUnformatted("Suggested Permissions:"); + + DrawPermissionRow("Sounds", _joinInfo.GroupPermissions.IsPreferDisableSounds(), _ownPermissions.DisableGroupSounds, v => _ownPermissions.DisableGroupSounds = v); + DrawPermissionRow("Animations", _joinInfo.GroupPermissions.IsPreferDisableAnimations(), _ownPermissions.DisableGroupAnimations, v => _ownPermissions.DisableGroupAnimations = v); + DrawPermissionRow("VFX", _joinInfo.GroupPermissions.IsPreferDisableVFX(), _ownPermissions.DisableGroupVFX, v => _ownPermissions.DisableGroupVFX = v); + + ImGui.NewLine(); + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Plus, $"Finalize and join {_joinDto.Group.AliasOrGID}")) + { + var finalPermissions = GroupUserPreferredPermissions.NoneSet; + finalPermissions.SetDisableSounds(_ownPermissions.DisableGroupSounds); + finalPermissions.SetDisableAnimations(_ownPermissions.DisableGroupAnimations); + finalPermissions.SetDisableVFX(_ownPermissions.DisableGroupVFX); + + _ = _apiController.GroupJoinFinalize(new GroupJoinDto(_joinDto.Group, _joinDto.Password, finalPermissions)); + _recentlyJoined.Add(_joinDto.Group.GID); + + _joinDto = null; + _joinInfo = null; + } + + ImGui.SameLine(); + if (ImGui.Button("Cancel")) + { + _joinDto = null; + _joinInfo = null; + } + } + + private void DrawPermissionRow(string label, bool suggested, bool current, Action apply) + { + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted($"- {label}"); + + ImGui.SameLine(120 * ImGuiHelpers.GlobalScale); + ImGui.Text("Current:"); + ImGui.SameLine(); + _uiSharedService.BooleanToColoredIcon(!current); + + ImGui.SameLine(240 * ImGuiHelpers.GlobalScale); + ImGui.Text("Suggested:"); + ImGui.SameLine(); + _uiSharedService.BooleanToColoredIcon(!suggested); + + ImGui.SameLine(380 * ImGuiHelpers.GlobalScale); + using var id = ImRaii.PushId(label); + if (current != suggested) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowRight, "Apply")) + apply(suggested); + } + + ImGui.NewLine(); + } + + #endregion + + #region Broadcast Settings Tab + + private void DrawBroadcastSettingsTab() + { + _uiSharedService.MediumText("Syncshell Broadcasting", UIColors.Get("PairBlue")); + ImGuiHelpers.ScaledDummy(2f); + + ImGui.PushTextWrapPos(); + ImGui.TextColored(ImGuiColors.DalamudGrey, "Broadcast your Syncshell to nearby Lightfinder users. They can then join directly from the Nearby Syncshells tab."); + ImGui.PopTextWrapPos(); + + ImGuiHelpers.ScaledDummy(4f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); + ImGuiHelpers.ScaledDummy(4f); + + bool isBroadcasting = _broadcastService.IsBroadcasting; + + if (isBroadcasting) + { + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), + new SeStringUtils.RichTextEntry("Settings can only be changed while Lightfinder is disabled.", UIColors.Get("LightlessYellow"))); + ImGuiHelpers.ScaledDummy(4f); + } + + if (isBroadcasting) + ImGui.BeginDisabled(); + + bool shellFinderEnabled = _configService.Current.SyncshellFinderEnabled; + if (ImGui.Checkbox("Enable Syncshell Broadcasting", ref shellFinderEnabled)) + { + _configService.Current.SyncshellFinderEnabled = shellFinderEnabled; + _configService.Save(); + } + UiSharedService.AttachToolTip("When enabled and Lightfinder is active, your selected Syncshell will be visible to nearby users."); + + ImGuiHelpers.ScaledDummy(4f); + + ImGui.Text("Select Syncshell to broadcast:"); + + var selectedGid = _configService.Current.SelectedFinderSyncshell; + var currentOption = _syncshellOptions.FirstOrDefault(o => string.Equals(o.GID, selectedGid, StringComparison.Ordinal)); + var preview = currentOption.Label ?? "Select a Syncshell..."; + + ImGui.SetNextItemWidth(300 * ImGuiHelpers.GlobalScale); + if (ImGui.BeginCombo("##SyncshellDropdown", preview)) + { + foreach (var (label, gid, available) in _syncshellOptions) + { + bool isSelected = string.Equals(gid, selectedGid, StringComparison.Ordinal); + + if (!available) + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); + + if (ImGui.Selectable(label, isSelected)) + { + _configService.Current.SelectedFinderSyncshell = gid; + _configService.Save(); + _ = RefreshSyncshellsAsync(); + } + + if (!available && ImGui.IsItemHovered()) + ImGui.SetTooltip("This Syncshell is not available on the current service."); + + if (!available) + ImGui.PopStyleColor(); + + if (isSelected) + ImGui.SetItemDefaultFocus(); + } + + ImGui.EndCombo(); + } + + if (isBroadcasting) + ImGui.EndDisabled(); + + ImGui.SameLine(); + if (_uiSharedService.IconButton(FontAwesomeIcon.Sync)) + _ = RefreshSyncshellsAsync(); + UiSharedService.AttachToolTip("Refresh Syncshell list"); + + ImGuiHelpers.ScaledDummy(8f); + + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8f); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Cog, "Advanced Settings")) + Mediator.Publish(new OpenLightfinderSettingsMessage()); + ImGui.PopStyleVar(); + UiSharedService.AttachToolTip("Open Lightfinder settings in the Settings window."); + } + + #endregion + + #region Help Tab + + private void DrawHelpTab() + { + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(4, 4)); + + _uiSharedService.MediumText("What is Lightfinder?", UIColors.Get("PairBlue")); + ImGui.PushTextWrapPos(); + ImGui.TextColored(ImGuiColors.DalamudGrey, "Lightfinder lets other Lightless users know you use Lightless. While enabled, you and others can see each other via a nameplate label."); + ImGui.PopTextWrapPos(); + + ImGuiHelpers.ScaledDummy(6f); + + _uiSharedService.MediumText("Pairing", UIColors.Get("PairBlue")); + ImGui.PushTextWrapPos(); + ImGui.TextColored(ImGuiColors.DalamudGrey, "Pairing can be initiated via the right-click context menu on another player. The process requires mutual confirmation from both users."); + ImGui.PopTextWrapPos(); + + ImGuiHelpers.ScaledDummy(2f); + + _uiSharedService.DrawNoteLine("", UIColors.Get("LightlessGreen"), + new SeStringUtils.RichTextEntry("If Lightfinder is "), + new SeStringUtils.RichTextEntry("ENABLED", UIColors.Get("LightlessGreen"), true), + new SeStringUtils.RichTextEntry(", the receiving user will get notified about pair requests.")); + + _uiSharedService.DrawNoteLine("", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("If Lightfinder is "), + new SeStringUtils.RichTextEntry("DISABLED", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry(", pair requests will NOT be visible to the recipient.")); + + ImGuiHelpers.ScaledDummy(6f); + + _uiSharedService.MediumText("Privacy", UIColors.Get("PairBlue")); + + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), + new SeStringUtils.RichTextEntry("Lightfinder is entirely "), + new SeStringUtils.RichTextEntry("opt-in", UIColors.Get("LightlessYellow"), true), + new SeStringUtils.RichTextEntry(" and does not share personal data with other users.")); + + ImGui.PushTextWrapPos(); + ImGui.TextColored(ImGuiColors.DalamudGrey, "All identifying information remains private to the server. Use Lightfinder when you're okay with being visible to other users."); + ImGui.PopTextWrapPos(); + + ImGuiHelpers.ScaledDummy(6f); + + _uiSharedService.MediumText("Syncshell Broadcasting", UIColors.Get("PairBlue")); + ImGui.PushTextWrapPos(); + ImGui.TextColored(ImGuiColors.DalamudGrey, "You can broadcast a Syncshell you own or moderate to nearby Lightfinder users. Configure this in the Broadcast Settings tab."); + ImGui.PopTextWrapPos(); + + ImGui.PopStyleVar(); + } + + #endregion + +#if DEBUG + #region Debug Tab + + private void DrawDebugTab() + { + ImGui.Text("Broadcast Cache"); + + if (ImGui.BeginTable("##BroadcastCacheTable", 4, ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.ScrollY, new Vector2(-1, 200f))) + { + ImGui.TableSetupColumn("CID", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Broadcasting", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Expires In", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Syncshell GID", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableHeadersRow(); + + var now = DateTime.UtcNow; + + foreach (var (cid, entry) in _broadcastScannerService.BroadcastCache) + { + ImGui.TableNextRow(); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(cid.Truncate(12)); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(cid); + + ImGui.TableNextColumn(); + var colorBroadcast = entry.IsBroadcasting ? UIColors.Get("LightlessGreen") : UIColors.Get("DimRed"); + ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, ImGui.GetColorU32(colorBroadcast)); + ImGui.TextUnformatted(entry.IsBroadcasting.ToString()); + + ImGui.TableNextColumn(); + var remaining = entry.ExpiryTime - now; + var colorTtl = remaining <= TimeSpan.Zero ? UIColors.Get("DimRed") + : remaining < TimeSpan.FromSeconds(10) ? UIColors.Get("LightlessYellow") + : (Vector4?)null; + + if (colorTtl != null) + ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, ImGui.GetColorU32(colorTtl.Value)); + + ImGui.TextUnformatted(remaining > TimeSpan.Zero ? remaining.ToString("hh\\:mm\\:ss") : "Expired"); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(entry.GID ?? "-"); + } + + ImGui.EndTable(); + } + } + + #endregion +#endif + + #region Data Refresh + + private async Task RefreshSyncshellsAsync() + { + if (!_apiController.IsConnected) + { + _allSyncshells = []; + RebuildSyncshellDropdownOptions(); + return; + } + + try + { + _allSyncshells = await _apiController.GroupsGetAll().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch Syncshells."); + _allSyncshells = []; + } + + RebuildSyncshellDropdownOptions(); + } + + private void RebuildSyncshellDropdownOptions() + { + var selectedGid = _configService.Current.SelectedFinderSyncshell; + var allSyncshells = _allSyncshells ?? []; + var filteredSyncshells = allSyncshells + .Where(g => string.Equals(g.OwnerUID, _userUid, StringComparison.Ordinal) || g.GroupUserInfo.IsModerator()) + .ToList(); + + _syncshellOptions.Clear(); + _syncshellOptions.Add(("None", null, true)); + + var addedGids = new HashSet(StringComparer.Ordinal); + + foreach (var shell in filteredSyncshells) + { + var label = shell.GroupAliasOrGID ?? shell.GID; + _syncshellOptions.Add((label, shell.GID, true)); + addedGids.Add(shell.GID); + } + + if (!string.IsNullOrEmpty(selectedGid) && !addedGids.Contains(selectedGid)) + { + var matching = allSyncshells.FirstOrDefault(g => string.Equals(g.GID, selectedGid, StringComparison.Ordinal)); + if (matching != null) + { + var label = matching.GroupAliasOrGID ?? matching.GID; + _syncshellOptions.Add((label, matching.GID, true)); + addedGids.Add(matching.GID); } } - public Task RefreshSyncshells() - { - return RefreshSyncshellsInternal(); - } + if (!string.IsNullOrEmpty(selectedGid) && !addedGids.Contains(selectedGid)) + _syncshellOptions.Add(($"[Unavailable] {selectedGid}", selectedGid, false)); + } - private async Task RefreshSyncshellsInternal() + private async Task RefreshNearbySyncshellsAsync(string? gid = null) + { + var syncshellBroadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts(); + var snapshot = _pairUiService.GetSnapshot(); + _currentSyncshells = [.. snapshot.GroupPairs.Keys]; + + _recentlyJoined.RemoveWhere(g => _currentSyncshells.Exists(s => string.Equals(s.GID, g, StringComparison.Ordinal))); + + List? updatedList = []; + +#if DEBUG + if (_useTestSyncshells) { - if (!_apiController.IsConnected) + updatedList = BuildTestSyncshells(); + } + else +#endif + { + if (syncshellBroadcasts.Count == 0) { - _allSyncshells = []; - RebuildSyncshellDropdownOptions(); + ClearSyncshells(); return; } try { - _allSyncshells = await _apiController.GroupsGetAll().ConfigureAwait(false); + var groups = await _apiController.GetBroadcastedGroups(syncshellBroadcasts).ConfigureAwait(false); + updatedList = groups?.DistinctBy(g => g.Group.GID).ToList(); } catch (Exception ex) { - _logger.LogError(ex, "Failed to fetch Syncshells."); - _allSyncshells = []; + _logger.LogError(ex, "Failed to refresh broadcasted syncshells."); + return; } - - RebuildSyncshellDropdownOptions(); } - - public override void OnOpen() + if (updatedList == null || updatedList.Count == 0) { - _userUid = _apiController.UID; - _ = RefreshSyncshells(); + ClearSyncshells(); + return; } - protected override void DrawInternal() + if (gid != null && _recentlyJoined.Contains(gid)) + _recentlyJoined.Clear(); + + var previousGid = GetSelectedGid(); + + _nearbySyncshells.Clear(); + _nearbySyncshells.AddRange(updatedList); + + if (previousGid != null) { - if (!_broadcastService.IsLightFinderAvailable) + var newIndex = _nearbySyncshells.FindIndex(s => string.Equals(s.Group.GID, previousGid, StringComparison.Ordinal)); + if (newIndex >= 0) { - _uiSharedService.MediumText("This server doesn't support Lightfinder.", UIColors.Get("LightlessYellow")); - - ImGuiHelpers.ScaledDummy(0.25f); + _selectedNearbyIndex = newIndex; + return; } + } - if (ImGui.BeginTabBar("##BroadcastTabs")) - { - if (ImGui.BeginTabItem("Lightfinder")) - { - _uiSharedService.MediumText("Lightfinder", UIColors.Get("PairBlue")); - - ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(1, -2)); - - _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessPurple"),"This lets other Lightless users know you use Lightless. While enabled, you and others using Lightfinder can see each other identified as Lightless users."); - - ImGui.Indent(15f); - ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); - ImGui.Text("- This is done using a 'Lightless' label above player nameplates."); - ImGui.PopStyleColor(); - ImGui.Unindent(15f); - - ImGuiHelpers.ScaledDummy(3f); - - _uiSharedService.MediumText("Pairing", UIColors.Get("PairBlue")); - _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessPurple"), "Pairing may be initiated via the right-click context menu on another player." + - " The process requires mutual confirmation: the sender initiates the request, and the recipient completes it by responding with a request in return."); - - _uiSharedService.DrawNoteLine( - "! ", - UIColors.Get("LightlessYellow"), - new SeStringUtils.RichTextEntry("If Lightfinder is "), - new SeStringUtils.RichTextEntry("ENABLED", UIColors.Get("LightlessGreen"), true), - new SeStringUtils.RichTextEntry(" when a pair request is made, the receiving user will get notified about it.")); - - _uiSharedService.DrawNoteLine( - "! ", - UIColors.Get("LightlessYellow"), - new SeStringUtils.RichTextEntry("If Lightfinder is "), - new SeStringUtils.RichTextEntry("DISABLED", UIColors.Get("DimRed"), true), - new SeStringUtils.RichTextEntry(" when a pair request is made, the receiving user will "), - new SeStringUtils.RichTextEntry("NOT", UIColors.Get("DimRed"), true), - new SeStringUtils.RichTextEntry(" get a notification, and the request will not be visible to them in any way.")); - - ImGuiHelpers.ScaledDummy(3f); - - _uiSharedService.MediumText("Privacy", UIColors.Get("PairBlue")); - - _uiSharedService.DrawNoteLine( - "! ", - UIColors.Get("DimRed"), - new SeStringUtils.RichTextEntry("Lightfinder is entirely "), - new SeStringUtils.RichTextEntry("opt-in", UIColors.Get("LightlessYellow"), true), - new SeStringUtils.RichTextEntry(" and does not share any data with other users. All identifying information remains private to the server.")); - - _uiSharedService.DrawNoteLine("! ", UIColors.Get("DimRed"), "Pairing is intended as a mutual agreement between both parties. A pair request will not be visible to the recipient unless Lightfinder is enabled."); - - ImGuiHelpers.ScaledDummy(5f); - - ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); - ImGui.TextWrapped("Use Lightfinder when you're okay with being visible to other users and understand that you are responsible for your own experience."); - ImGui.PopStyleColor(); - - ImGui.PopStyleVar(); - - ImGuiHelpers.ScaledDummy(3f); - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); - - if (_configService.Current.BroadcastEnabled) - { - ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessGreen")); - ImGui.Text("The Lightfinder calls, and somewhere, a soul may answer."); // cringe.. - ImGui.PopStyleColor(); - - var ttl = _broadcastService.RemainingTtl; - if (ttl is { } remaining && remaining > TimeSpan.Zero) - { - ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessYellow")); - ImGui.Text($"Still shining, for {remaining:hh\\:mm\\:ss}"); - ImGui.PopStyleColor(); - } - else - { - ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); - ImGui.Text("The Lightfinder’s light wanes, but not in vain."); // cringe.. - ImGui.PopStyleColor(); - } - } - else - { - ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); - ImGui.Text("The Lightfinder rests, waiting to shine again."); // cringe.. - ImGui.PopStyleColor(); - } - - var cooldown = _broadcastService.RemainingCooldown; - if (cooldown is { } cd) - { - ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); - ImGui.Text($"The Lightfinder gathers its strength... ({Math.Ceiling(cd.TotalSeconds)}s)"); - ImGui.PopStyleColor(); - } - - ImGuiHelpers.ScaledDummy(0.5f); - - bool isBroadcasting = _broadcastService.IsBroadcasting; - bool isOnCooldown = cooldown.HasValue && cooldown.Value.TotalSeconds > 0; - - ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 10.0f); - - if (isOnCooldown) - ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("DimRed")); - else if (isBroadcasting) - ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessGreen")); - else - ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("PairBlue")); - - if (isOnCooldown || !_broadcastService.IsLightFinderAvailable) - ImGui.BeginDisabled(); - - string buttonText = isBroadcasting ? "Disable Lightfinder" : "Enable Lightfinder"; - - if (ImGui.Button(buttonText, new Vector2(200 * ImGuiHelpers.GlobalScale, 0))) - { - _broadcastService.ToggleBroadcast(); - } - - var toggleButtonHeight = ImGui.GetItemRectSize().Y; - - if (isOnCooldown || !_broadcastService.IsLightFinderAvailable) - ImGui.EndDisabled(); - - ImGui.PopStyleColor(); - ImGui.PopStyleVar(); - - ImGui.SameLine(); - if (_uiSharedService.IconButton(FontAwesomeIcon.Cog, toggleButtonHeight)) - { - Mediator.Publish(new OpenLightfinderSettingsMessage()); - } - - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.TextUnformatted("Open Lightfinder settings."); - ImGui.EndTooltip(); - } - - ImGui.EndTabItem(); - } - - if (ImGui.BeginTabItem("Syncshell Finder")) - { - if (_allSyncshells == null) - { - ImGui.Text("Loading Syncshells..."); - _ = RefreshSyncshells(); - return; - } - - _uiSharedService.MediumText("Syncshell Finder", UIColors.Get("PairBlue")); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); - - ImGui.PushTextWrapPos(); - ImGui.Text("Allow your owned Syncshell to be indexed by the Nearby Syncshell Finder."); - ImGui.Text("To enable this, select one of your owned Syncshells from the dropdown menu below and ensure that \"Toggle Syncshell Finder\" is enabled. Your Syncshell will be visible in the Nearby Syncshell Finder as long as Lightfinder is active."); - ImGui.PopTextWrapPos(); - - ImGuiHelpers.ScaledDummy(0.2f); - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); - - bool ShellFinderEnabled = _configService.Current.SyncshellFinderEnabled; - bool isBroadcasting = _broadcastService.IsBroadcasting; - - if (isBroadcasting) - { - var warningColor = UIColors.Get("LightlessYellow"); - _uiSharedService.DrawNoteLine("! ", warningColor, - new SeStringUtils.RichTextEntry("Syncshell Finder can only be changed while Lightfinder is disabled.", warningColor)); - ImGuiHelpers.ScaledDummy(0.2f); - } - - if (isBroadcasting) - ImGui.BeginDisabled(); - - if (ImGui.Checkbox("Toggle Syncshell Finder", ref ShellFinderEnabled)) - { - _configService.Current.SyncshellFinderEnabled = ShellFinderEnabled; - _configService.Save(); - } - - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.Text("Toggle to broadcast specified Syncshell."); - ImGui.EndTooltip(); - } - - var selectedGid = _configService.Current.SelectedFinderSyncshell; - var currentOption = _syncshellOptions.FirstOrDefault(o => string.Equals(o.GID, selectedGid, StringComparison.Ordinal)); - var preview = currentOption.Label ?? "Select a Syncshell..."; - - if (ImGui.BeginCombo("##SyncshellDropdown", preview)) - { - foreach (var (label, gid, available) in _syncshellOptions) - { - bool isSelected = string.Equals(gid, selectedGid, StringComparison.Ordinal); - - if (!available) - ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); - - if (ImGui.Selectable(label, isSelected)) - { - _configService.Current.SelectedFinderSyncshell = gid; - _configService.Save(); - _ = RefreshSyncshells(); - } - - if (!available && ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.Text("This Syncshell is not available on the current service."); - ImGui.EndTooltip(); - } - - if (!available) - ImGui.PopStyleColor(); - - if (isSelected) - ImGui.SetItemDefaultFocus(); - } - - ImGui.EndCombo(); - } - - - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.Text("Choose one of the available options."); - ImGui.EndTooltip(); - } - - - if (isBroadcasting) - ImGui.EndDisabled(); - - ImGui.EndTabItem(); - } + ClearSelection(); + } #if DEBUG - if (ImGui.BeginTabItem("Debug")) - { - ImGui.Text("Broadcast Cache"); - - if (ImGui.BeginTable("##BroadcastCacheTable", 4, ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.ScrollY, new Vector2(-1, 225f))) - { - ImGui.TableSetupColumn("CID", ImGuiTableColumnFlags.WidthStretch); - ImGui.TableSetupColumn("IsBroadcasting", ImGuiTableColumnFlags.WidthStretch); - ImGui.TableSetupColumn("Expires In", ImGuiTableColumnFlags.WidthStretch); - ImGui.TableSetupColumn("Syncshell GID", ImGuiTableColumnFlags.WidthStretch); - ImGui.TableHeadersRow(); - - var now = DateTime.UtcNow; - - foreach (var (cid, entry) in _broadcastScannerService.BroadcastCache) - { - ImGui.TableNextRow(); - - ImGui.TableNextColumn(); - ImGui.TextUnformatted(cid.Truncate(12)); - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.TextUnformatted(cid); - ImGui.EndTooltip(); - } - - ImGui.TableNextColumn(); - var colorBroadcast = entry.IsBroadcasting - ? UIColors.Get("LightlessGreen") - : UIColors.Get("DimRed"); - - ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, ImGui.GetColorU32(colorBroadcast)); - ImGui.TextUnformatted(entry.IsBroadcasting.ToString()); - - ImGui.TableNextColumn(); - var remaining = entry.ExpiryTime - now; - var colorTtl = - remaining <= TimeSpan.Zero ? UIColors.Get("DimRed") : - remaining < TimeSpan.FromSeconds(10) ? UIColors.Get("LightlessYellow") : - (Vector4?)null; - - if (colorTtl != null) - ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, ImGui.GetColorU32(colorTtl.Value)); - - ImGui.TextUnformatted(remaining > TimeSpan.Zero - ? remaining.ToString("hh\\:mm\\:ss") - : "Expired"); - - ImGui.TableNextColumn(); - ImGui.TextUnformatted(entry.GID ?? "-"); - } - - ImGui.EndTable(); - } - - ImGui.EndTabItem(); - } + private static List BuildTestSyncshells() + { + return + [ + new(new GroupData("TEST-ALPHA", "Alpha Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-BETA", "Beta Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-GAMMA", "Gamma Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-DELTA", "Delta Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-EPSILON", "Epsilon Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-ZETA", "Zeta Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-ETA", "Eta Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-THETA", "Theta Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-IOTA", "Iota Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-KAPPA", "Kappa Shell"), "", GroupUserPreferredPermissions.NoneSet), + ]; + } #endif - ImGui.EndTabBar(); + private void ClearSyncshells() + { + if (_nearbySyncshells.Count == 0) return; + _nearbySyncshells.Clear(); + ClearSelection(); + } + + private void ClearSelection() + { + _selectedNearbyIndex = -1; + _joinDto = null; + _joinInfo = null; + } + + private string? GetSelectedGid() + { + if (_selectedNearbyIndex < 0 || _selectedNearbyIndex >= _nearbySyncshells.Count) + return null; + return _nearbySyncshells[_selectedNearbyIndex].Group.GID; + } + + #endregion + + #region Helpers + + private (float widthUsed, float rowHeight) RenderProfileTagsSingleRow(IReadOnlyList tags, float scale) + { + if (tags == null || tags.Count == 0) + return (0f, 0f); + + var drawList = ImGui.GetWindowDrawList(); + var style = ImGui.GetStyle(); + var defaultTextColorU32 = ImGui.GetColorU32(ImGuiCol.Text); + + var baseLocal = ImGui.GetCursorPos(); + var baseScreen = ImGui.GetCursorScreenPos(); + float availableWidth = ImGui.GetContentRegionAvail().X; + if (availableWidth <= 0f) + availableWidth = 1f; + + float cursorLocalX = baseLocal.X; + float cursorScreenX = baseScreen.X; + float rowHeight = 0f; + + for (int i = 0; i < tags.Count; i++) + { + var tag = tags[i]; + if (!tag.HasContent) + continue; + + var tagSize = ProfileTagRenderer.MeasureTag(tag, scale, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _seResolvedSegments, GetIconWrap, _logger); + + float tagWidth = tagSize.X; + float tagHeight = tagSize.Y; + + if (cursorLocalX > baseLocal.X && cursorLocalX + tagWidth > baseLocal.X + availableWidth) + break; + + var tagScreenPos = new Vector2(cursorScreenX, baseScreen.Y); + ImGui.SetCursorScreenPos(tagScreenPos); + ImGui.InvisibleButton($"##profileTagInline_{i}", tagSize); + + ProfileTagRenderer.RenderTag(tag, tagScreenPos, scale, drawList, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _seResolvedSegments, GetIconWrap, _logger); + + cursorLocalX += tagWidth + style.ItemSpacing.X; + cursorScreenX += tagWidth + style.ItemSpacing.X; + rowHeight = MathF.Max(rowHeight, tagHeight); + } + + ImGui.SetCursorPos(new Vector2(baseLocal.X, baseLocal.Y + rowHeight)); + + float widthUsed = cursorLocalX - baseLocal.X; + return (widthUsed, rowHeight); + } + + private static string TruncateTextToWidth(string text, float maxWidth) + { + if (string.IsNullOrEmpty(text)) + return text; + + const string ellipsis = "..."; + float ellipsisWidth = ImGui.CalcTextSize(ellipsis).X; + + if (maxWidth <= ellipsisWidth) + return ellipsis; + + int low = 0; + int high = text.Length; + string best = ellipsis; + + while (low <= high) + { + int mid = (low + high) / 2; + string candidate = string.Concat(text.AsSpan(0, mid), ellipsis); + float width = ImGui.CalcTextSize(candidate).X; + + if (width <= maxWidth) + { + best = candidate; + low = mid + 1; + } + else + { + high = mid - 1; } } + + return best; } + + private IDalamudTextureWrap? GetIconWrap(uint iconId) + { + try + { + if (_uiSharedService.TryGetIcon(iconId, out var wrap) && wrap != null) + return wrap; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to resolve icon {IconId}", iconId); + } + + return null; + } + + #endregion } diff --git a/LightlessSync/UI/SyncshellFinderUI.cs b/LightlessSync/UI/SyncshellFinderUI.cs deleted file mode 100644 index 0586c06..0000000 --- a/LightlessSync/UI/SyncshellFinderUI.cs +++ /dev/null @@ -1,850 +0,0 @@ -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 Dalamud.Plugin.Services; -using LightlessSync.API.Data; -using LightlessSync.API.Data.Enum; -using LightlessSync.API.Data.Extensions; -using LightlessSync.API.Dto; -using LightlessSync.API.Dto.Group; -using LightlessSync.Services; -using LightlessSync.Services.LightFinder; -using LightlessSync.Services.Mediator; -using LightlessSync.UI.Services; -using LightlessSync.UI.Tags; -using LightlessSync.Utils; -using LightlessSync.WebAPI; -using Microsoft.Extensions.Logging; -using System.Numerics; - -namespace LightlessSync.UI; - -public class SyncshellFinderUI : WindowMediatorSubscriberBase -{ - private readonly ApiController _apiController; - private readonly LightFinderService _broadcastService; - private readonly UiSharedService _uiSharedService; - private readonly LightFinderScannerService _broadcastScannerService; - private readonly PairUiService _pairUiService; - private readonly DalamudUtilService _dalamudUtilService; - - private Vector4 _tagBackgroundColor = new(0.18f, 0.18f, 0.18f, 0.95f); - private Vector4 _tagBorderColor = new(0.35f, 0.35f, 0.35f, 0.4f); - - private readonly List _seResolvedSegments = new(); - private readonly List _nearbySyncshells = []; - private List _currentSyncshells = []; - private int _selectedNearbyIndex = -1; - private int _syncshellPageIndex = 0; - private readonly HashSet _recentlyJoined = new(StringComparer.Ordinal); - - private GroupJoinDto? _joinDto; - private GroupJoinInfoDto? _joinInfo; - private DefaultPermissionsDto _ownPermissions = null!; - private bool _useTestSyncshells = false; - - private bool _compactView = false; - private readonly LightlessProfileManager _lightlessProfileManager; - - public SyncshellFinderUI( - ILogger logger, - LightlessMediator mediator, - PerformanceCollectorService performanceCollectorService, - LightFinderService broadcastService, - UiSharedService uiShared, - ApiController apiController, - LightFinderScannerService broadcastScannerService, - PairUiService pairUiService, - DalamudUtilService dalamudUtilService, - LightlessProfileManager lightlessProfileManager) : base(logger, mediator, "Shellfinder###LightlessSyncshellFinderUI", performanceCollectorService) - { - _broadcastService = broadcastService; - _uiSharedService = uiShared; - _apiController = apiController; - _broadcastScannerService = broadcastScannerService; - _pairUiService = pairUiService; - _dalamudUtilService = dalamudUtilService; - _lightlessProfileManager = lightlessProfileManager; - - IsOpen = false; - WindowBuilder.For(this) - .SetSizeConstraints(new Vector2(600, 400), new Vector2(600, 550)) - .Apply(); - - Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync().ConfigureAwait(false)); - Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync().ConfigureAwait(false)); - Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync(_.gid).ConfigureAwait(false)); - Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync(_.gid).ConfigureAwait(false)); - } - - public override async void OnOpen() - { - _ownPermissions = _apiController.DefaultPermissions.DeepClone()!; - await RefreshSyncshellsAsync().ConfigureAwait(false); - } - - protected override void DrawInternal() - { - ImGui.BeginGroup(); - _uiSharedService.MediumText("Nearby Syncshells", UIColors.Get("LightlessPurple")); - -#if DEBUG - if (ImGui.SmallButton("Show test syncshells")) - { - _useTestSyncshells = !_useTestSyncshells; - _ = Task.Run(async () => await RefreshSyncshellsAsync().ConfigureAwait(false)); - } - ImGui.SameLine(); -#endif - - string checkboxLabel = "Compact view"; - float availWidth = ImGui.GetContentRegionAvail().X; - float checkboxWidth = ImGui.CalcTextSize(checkboxLabel).X + ImGui.GetFrameHeight(); - - float rightX = ImGui.GetCursorPosX() + availWidth - checkboxWidth - 4.0f; - ImGui.SetCursorPosX(rightX); - ImGui.Checkbox(checkboxLabel, ref _compactView); - ImGui.EndGroup(); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); - ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); - if (_nearbySyncshells.Count == 0) - { - ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells are being broadcasted."); - - if (!_broadcastService.IsBroadcasting) - { - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow")); - - ImGui.TextColored(UIColors.Get("LightlessYellow"), "Lightfinder is currently disabled, to locate nearby syncshells, Lightfinder must be active."); - ImGuiHelpers.ScaledDummy(0.5f); - - ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 10.0f); - ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessPurple")); - - if (ImGui.Button("Open Lightfinder", new Vector2(200 * ImGuiHelpers.GlobalScale, 0))) - { - Mediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); - } - - ImGui.PopStyleColor(); - ImGui.PopStyleVar(); - - return; - } - - return; - } - - string? myHashedCid = null; - try - { - var cid = _dalamudUtilService.GetCID(); - myHashedCid = cid.ToString().GetHash256(); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to get CID, not excluding own broadcast."); - } - var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts().Where(b => !string.Equals(b.HashedCID, myHashedCid, StringComparison.Ordinal)).ToList() ?? []; - - var cardData = new List<(GroupJoinDto Shell, string BroadcasterName)>(); - - foreach (var shell in _nearbySyncshells) - { - string broadcasterName; - - if (shell?.Group == null || string.IsNullOrEmpty(shell.Group.GID)) - continue; - - if (_useTestSyncshells) - { - var displayName = !string.IsNullOrEmpty(shell.Group.Alias) - ? shell.Group.Alias - : shell.Group.GID; - - broadcasterName = $"{displayName} (Tester of TestWorld)"; - } - else - { - var broadcast = broadcasts - .FirstOrDefault(b => string.Equals(b.GID, shell.Group.GID, StringComparison.Ordinal)); - - if (broadcast == null) - continue; - - var (name, address) = _dalamudUtilService.FindPlayerByNameHash(broadcast.HashedCID); - if (string.IsNullOrEmpty(name)) - continue; - - var worldName = _dalamudUtilService.GetWorldNameFromPlayerAddress(address); - broadcasterName = !string.IsNullOrEmpty(worldName) - ? $"{name} ({worldName})" - : name; - } - - cardData.Add((shell, broadcasterName)); - } - - if (cardData.Count == 0) - { - ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells are being broadcasted."); - return; - } - - if (_compactView) - { - DrawSyncshellGrid(cardData); - } - else - { - DrawSyncshellList(cardData); - } - - - if (_joinDto != null && _joinInfo != null && _joinInfo.Success) - DrawConfirmation(); - } - - private void DrawSyncshellList(List<(GroupJoinDto Shell, string BroadcasterName)> listData) - { - const int shellsPerPage = 3; - var totalPages = (int)Math.Ceiling(listData.Count / (float)shellsPerPage); - if (totalPages <= 0) - totalPages = 1; - - _syncshellPageIndex = Math.Clamp(_syncshellPageIndex, 0, totalPages - 1); - - var firstIndex = _syncshellPageIndex * shellsPerPage; - var lastExclusive = Math.Min(firstIndex + shellsPerPage, listData.Count); - - ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8.0f); - ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1.0f); - - for (int index = firstIndex; index < lastExclusive; index++) - { - var (shell, broadcasterName) = listData[index]; - - ImGui.PushID(shell.Group.GID); - float rowHeight = 74f * ImGuiHelpers.GlobalScale; - - ImGui.BeginChild($"ShellRow##{shell.Group.GID}", new Vector2(-1, rowHeight), border: true); - - var displayName = !string.IsNullOrEmpty(shell.Group.Alias) ? shell.Group.Alias : shell.Group.GID; - - var style = ImGui.GetStyle(); - float startX = ImGui.GetCursorPosX(); - float regionW = ImGui.GetContentRegionAvail().X; - float rightTxtW = ImGui.CalcTextSize(broadcasterName).X; - - _uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple")); - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Click to open profile."); - if (ImGui.IsItemClicked()) - { - Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group)); - } - - float rightX = startX + regionW - rightTxtW - style.ItemSpacing.X; - ImGui.SameLine(); - ImGui.SetCursorPosX(rightX); - ImGui.TextUnformatted(broadcasterName); - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Broadcaster of the syncshell."); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); - - var groupProfile = _lightlessProfileManager.GetLightlessGroupProfile(shell.Group); - - IReadOnlyList groupTags = - groupProfile != null && groupProfile.Tags.Count > 0 - ? ProfileTagService.ResolveTags(groupProfile.Tags) - : []; - - var limitedTags = groupTags.Count > 3 - ? [.. groupTags.Take(3)] - : groupTags; - - float tagScale = ImGuiHelpers.GlobalScale * 0.9f; - - Vector2 rowStartLocal = ImGui.GetCursorPos(); - - float tagsWidth = 0f; - float tagsHeight = 0f; - - if (limitedTags.Count > 0) - { - (tagsWidth, tagsHeight) = RenderProfileTagsSingleRow(limitedTags, tagScale); - } - else - { - ImGui.SetCursorPosX(startX); - ImGui.TextDisabled("-- No tags set --"); - ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); - } - - float btnBaselineY = rowStartLocal.Y; - float joinX = rowStartLocal.X + (tagsWidth > 0 ? tagsWidth + style.ItemSpacing.X : 0f); - - ImGui.SetCursorPos(new Vector2(joinX, btnBaselineY)); - DrawJoinButton(shell); - - float btnHeight = ImGui.GetFrameHeightWithSpacing(); - float rowHeightUsed = MathF.Max(tagsHeight, btnHeight); - - ImGui.SetCursorPos(new Vector2( - rowStartLocal.X, - rowStartLocal.Y + rowHeightUsed)); - - ImGui.EndChild(); - ImGui.PopID(); - - ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); - } - - ImGui.PopStyleVar(2); - - DrawPagination(totalPages); - } - - private void DrawSyncshellGrid(List<(GroupJoinDto Shell, string BroadcasterName)> cardData) - { - const int shellsPerPage = 4; - var totalPages = (int)Math.Ceiling(cardData.Count / (float)shellsPerPage); - if (totalPages <= 0) - totalPages = 1; - - _syncshellPageIndex = Math.Clamp(_syncshellPageIndex, 0, totalPages - 1); - - var firstIndex = _syncshellPageIndex * shellsPerPage; - var lastExclusive = Math.Min(firstIndex + shellsPerPage, cardData.Count); - - var avail = ImGui.GetContentRegionAvail(); - var spacing = ImGui.GetStyle().ItemSpacing; - - var cardWidth = (avail.X - spacing.X) / 2.0f; - var cardHeight = (avail.Y - spacing.Y - (ImGui.GetFrameHeightWithSpacing() * 2.0f)) / 2.0f; - cardHeight = MathF.Max(110f * ImGuiHelpers.GlobalScale, cardHeight); - - ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8.0f); - ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1.0f); - - for (int index = firstIndex; index < lastExclusive; index++) - { - var localIndex = index - firstIndex; - var (shell, broadcasterName) = cardData[index]; - - if (localIndex % 2 != 0) - ImGui.SameLine(); - - ImGui.PushID(shell.Group.GID); - - ImGui.BeginGroup(); - _ = ImGui.BeginChild("ShellCard##" + shell.Group.GID, new Vector2(cardWidth, cardHeight), border: true); - - var displayName = !string.IsNullOrEmpty(shell.Group.Alias) - ? shell.Group.Alias - : shell.Group.GID; - - var style = ImGui.GetStyle(); - float startX = ImGui.GetCursorPosX(); - float availW = ImGui.GetContentRegionAvail().X; - - ImGui.BeginGroup(); - - _uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple")); - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Click to open profile."); - if (ImGui.IsItemClicked()) - { - Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group)); - } - - float nameRightX = ImGui.GetItemRectMax().X; - - var regionMinScreen = ImGui.GetCursorScreenPos(); - float regionRightX = regionMinScreen.X + availW; - - float minBroadcasterX = nameRightX + style.ItemSpacing.X; - - float maxBroadcasterWidth = regionRightX - minBroadcasterX; - - string broadcasterToShow = broadcasterName; - - if (!string.IsNullOrEmpty(broadcasterName) && maxBroadcasterWidth > 0f) - { - float bcFullWidth = ImGui.CalcTextSize(broadcasterName).X; - string toolTip; - - if (bcFullWidth > maxBroadcasterWidth) - { - broadcasterToShow = TruncateTextToWidth(broadcasterName, maxBroadcasterWidth); - toolTip = broadcasterName + Environment.NewLine + Environment.NewLine + "Broadcaster of the syncshell."; - } - else - { - toolTip = "Broadcaster of the syncshell."; - } - - float bcWidth = ImGui.CalcTextSize(broadcasterToShow).X; - - float broadX = regionRightX - bcWidth; - - broadX = MathF.Max(broadX, minBroadcasterX); - - ImGui.SameLine(); - var curPos = ImGui.GetCursorPos(); - ImGui.SetCursorPos(new Vector2(broadX - regionMinScreen.X + startX, curPos.Y + 3f * ImGuiHelpers.GlobalScale)); - ImGui.TextUnformatted(broadcasterToShow); - if (ImGui.IsItemHovered()) - ImGui.SetTooltip(toolTip); - } - - ImGui.EndGroup(); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); - - ImGui.Dummy(new Vector2(0, 6 * ImGuiHelpers.GlobalScale)); - - var groupProfile = _lightlessProfileManager.GetLightlessGroupProfile(shell.Group); - - IReadOnlyList groupTags = - groupProfile != null && groupProfile.Tags.Count > 0 - ? ProfileTagService.ResolveTags(groupProfile.Tags) - : []; - - float tagScale = ImGuiHelpers.GlobalScale * 0.9f; - - if (groupTags.Count > 0) - { - var limitedTags = groupTags.Count > 2 - ? [.. groupTags.Take(2)] - : groupTags; - - ImGui.SetCursorPosX(startX); - - var (_, tagsHeight) = RenderProfileTagsSingleRow(limitedTags, tagScale); - - ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); - } - else - { - ImGui.SetCursorPosX(startX); - ImGui.TextDisabled("-- No tags set --"); - ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); - } - - var buttonHeight = ImGui.GetFrameHeightWithSpacing(); - var remainingY = ImGui.GetContentRegionAvail().Y - buttonHeight; - if (remainingY > 0) - ImGui.Dummy(new Vector2(0, remainingY)); - - DrawJoinButton(shell); - - ImGui.EndChild(); - ImGui.EndGroup(); - - ImGui.PopID(); - } - - ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); - ImGui.PopStyleVar(2); - - DrawPagination(totalPages); - } - - private void DrawPagination(int totalPages) - { - if (totalPages > 1) - { - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); - - var style = ImGui.GetStyle(); - string pageLabel = $"Page {_syncshellPageIndex + 1}/{totalPages}"; - - float prevWidth = ImGui.CalcTextSize("<").X + style.FramePadding.X * 2; - float nextWidth = ImGui.CalcTextSize(">").X + style.FramePadding.X * 2; - float textWidth = ImGui.CalcTextSize(pageLabel).X; - - float totalWidth = prevWidth + textWidth + nextWidth + style.ItemSpacing.X * 2; - - float availWidth = ImGui.GetContentRegionAvail().X; - float offsetX = (availWidth - totalWidth) * 0.5f; - - ImGui.SetCursorPosX(ImGui.GetCursorPosX() + offsetX); - - if (ImGui.Button("<##PrevSyncshellPage") && _syncshellPageIndex > 0) - _syncshellPageIndex--; - - ImGui.SameLine(); - ImGui.Text(pageLabel); - - ImGui.SameLine(); - if (ImGui.Button(">##NextSyncshellPage") && _syncshellPageIndex < totalPages - 1) - _syncshellPageIndex++; - } - } - - private void DrawJoinButton(dynamic shell) - { - const string visibleLabel = "Join"; - var label = $"{visibleLabel}##{shell.Group.GID}"; - - var isAlreadyMember = _currentSyncshells.Exists(g => string.Equals(g.GID, shell.GID, StringComparison.Ordinal)); - var isRecentlyJoined = _recentlyJoined.Contains(shell.GID); - - Vector2 buttonSize; - - if (!_compactView) - { - var style = ImGui.GetStyle(); - var textSize = ImGui.CalcTextSize(visibleLabel); - - var width = textSize.X + style.FramePadding.X * 20f; - buttonSize = new Vector2(width, 30f); - - float availX = ImGui.GetContentRegionAvail().X; - float curX = ImGui.GetCursorPosX(); - float newX = curX + (availX - buttonSize.X); - ImGui.SetCursorPosX(newX); - } - else - { - buttonSize = new Vector2(-1, 0); - } - - if (!isAlreadyMember && !isRecentlyJoined) - { - ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessGreen")); - ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f)); - ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreen").WithAlpha(0.75f)); - if (ImGui.Button(label, buttonSize)) - { - _logger.LogInformation($"Join requested for Syncshell {shell.Group.GID} ({shell.Group.Alias})"); - - _ = Task.Run(async () => - { - try - { - var info = await _apiController.GroupJoinHashed(new GroupJoinHashedDto( - shell.Group, - shell.Password, - shell.GroupUserPreferredPermissions - )).ConfigureAwait(false); - - if (info != null && info.Success) - { - _joinDto = new GroupJoinDto(shell.Group, shell.Password, shell.GroupUserPreferredPermissions); - _joinInfo = info; - _ownPermissions = _apiController.DefaultPermissions.DeepClone()!; - - _logger.LogInformation($"Fetched join info for {shell.Group.GID}"); - } - else - { - _logger.LogWarning($"Failed to join {shell.Group.GID}: info was null or unsuccessful"); - } - } - catch (Exception ex) - { - _logger.LogError(ex, $"Join failed for {shell.Group.GID}"); - } - }); - } - } - else - { - ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("DimRed")); - ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("DimRed").WithAlpha(0.85f)); - ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("DimRed").WithAlpha(0.75f)); - - using (ImRaii.Disabled()) - { - ImGui.Button(label, buttonSize); - } - - UiSharedService.AttachToolTip("Already a member or owner of this Syncshell."); - } - - ImGui.PopStyleColor(3); - } - - private (float widthUsed, float rowHeight) RenderProfileTagsSingleRow(IReadOnlyList tags, float scale) - { - if (tags == null || tags.Count == 0) - return (0f, 0f); - - var drawList = ImGui.GetWindowDrawList(); - var style = ImGui.GetStyle(); - var defaultTextColorU32 = ImGui.GetColorU32(ImGuiCol.Text); - - var baseLocal = ImGui.GetCursorPos(); - var baseScreen = ImGui.GetCursorScreenPos(); - float availableWidth = ImGui.GetContentRegionAvail().X; - if (availableWidth <= 0f) - availableWidth = 1f; - - float cursorLocalX = baseLocal.X; - float cursorScreenX = baseScreen.X; - float rowHeight = 0f; - - for (int i = 0; i < tags.Count; i++) - { - var tag = tags[i]; - if (!tag.HasContent) - continue; - - var tagSize = ProfileTagRenderer.MeasureTag(tag, scale, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _seResolvedSegments, GetIconWrap, _logger); - - float tagWidth = tagSize.X; - float tagHeight = tagSize.Y; - - if (cursorLocalX > baseLocal.X && cursorLocalX + tagWidth > baseLocal.X + availableWidth) - break; - - var tagScreenPos = new Vector2(cursorScreenX, baseScreen.Y); - ImGui.SetCursorScreenPos(tagScreenPos); - ImGui.InvisibleButton($"##profileTagInline_{i}", tagSize); - - ProfileTagRenderer.RenderTag(tag, tagScreenPos, scale, drawList, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _seResolvedSegments, GetIconWrap, _logger); - - cursorLocalX += tagWidth + style.ItemSpacing.X; - cursorScreenX += tagWidth + style.ItemSpacing.X; - rowHeight = MathF.Max(rowHeight, tagHeight); - } - - ImGui.SetCursorPos(new Vector2(baseLocal.X, baseLocal.Y + rowHeight)); - - float widthUsed = cursorLocalX - baseLocal.X; - return (widthUsed, rowHeight); - } - private static string TruncateTextToWidth(string text, float maxWidth) - { - if (string.IsNullOrEmpty(text)) - return text; - - const string ellipsis = "..."; - float ellipsisWidth = ImGui.CalcTextSize(ellipsis).X; - - if (maxWidth <= ellipsisWidth) - return ellipsis; - - int low = 0; - int high = text.Length; - string best = ellipsis; - - while (low <= high) - { - int mid = (low + high) / 2; - string candidate = string.Concat(text.AsSpan(0, mid), ellipsis); - float width = ImGui.CalcTextSize(candidate).X; - - if (width <= maxWidth) - { - best = candidate; - low = mid + 1; - } - else - { - high = mid - 1; - } - } - - return best; - } - - private IDalamudTextureWrap? GetIconWrap(uint iconId) - { - try - { - if (_uiSharedService.TryGetIcon(iconId, out var wrap) && wrap != null) - return wrap; - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to resolve icon {IconId} for profile tags", iconId); - } - - return null; - } - - private void DrawConfirmation() - { - if (_joinDto != null && _joinInfo != null) - { - ImGui.Separator(); - ImGui.TextUnformatted($"Join Syncshell: {_joinDto.Group.AliasOrGID} by {_joinInfo.OwnerAliasOrUID}"); - ImGuiHelpers.ScaledDummy(2f); - ImGui.TextUnformatted("Suggested Syncshell Permissions:"); - - DrawPermissionRow("Sounds", _joinInfo.GroupPermissions.IsPreferDisableSounds(), _ownPermissions.DisableGroupSounds, v => _ownPermissions.DisableGroupSounds = v); - DrawPermissionRow("Animations", _joinInfo.GroupPermissions.IsPreferDisableAnimations(), _ownPermissions.DisableGroupAnimations, v => _ownPermissions.DisableGroupAnimations = v); - DrawPermissionRow("VFX", _joinInfo.GroupPermissions.IsPreferDisableVFX(), _ownPermissions.DisableGroupVFX, v => _ownPermissions.DisableGroupVFX = v); - - ImGui.NewLine(); - ImGui.NewLine(); - - if (_uiSharedService.IconTextButton(Dalamud.Interface.FontAwesomeIcon.Plus, $"Finalize and join {_joinDto.Group.AliasOrGID}")) - { - var finalPermissions = GroupUserPreferredPermissions.NoneSet; - finalPermissions.SetDisableSounds(_ownPermissions.DisableGroupSounds); - finalPermissions.SetDisableAnimations(_ownPermissions.DisableGroupAnimations); - finalPermissions.SetDisableVFX(_ownPermissions.DisableGroupVFX); - - _ = _apiController.GroupJoinFinalize(new GroupJoinDto(_joinDto.Group, _joinDto.Password, finalPermissions)); - - _recentlyJoined.Add(_joinDto.Group.GID); - - _joinDto = null; - _joinInfo = null; - } - } - } - - private void DrawPermissionRow(string label, bool suggested, bool current, Action apply) - { - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted($"- {label}"); - - ImGui.SameLine(150 * ImGuiHelpers.GlobalScale); - ImGui.TextUnformatted("Current:"); - ImGui.SameLine(); - _uiSharedService.BooleanToColoredIcon(!current); - - ImGui.SameLine(300 * ImGuiHelpers.GlobalScale); - ImGui.TextUnformatted("Suggested:"); - ImGui.SameLine(); - _uiSharedService.BooleanToColoredIcon(!suggested); - - ImGui.SameLine(450 * ImGuiHelpers.GlobalScale); - using var id = ImRaii.PushId(label); - if (current != suggested) - { - if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowRight, "Apply")) - apply(suggested); - } - - ImGui.NewLine(); - } - - private async Task RefreshSyncshellsAsync(string? gid = null) - { - var syncshellBroadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts(); - var snapshot = _pairUiService.GetSnapshot(); - _currentSyncshells = [.. snapshot.GroupPairs.Keys]; - - _recentlyJoined.RemoveWhere(gid => - _currentSyncshells.Exists(s => string.Equals(s.GID, gid, StringComparison.Ordinal))); - - List? updatedList = []; - - if (_useTestSyncshells) - { - updatedList = BuildTestSyncshells(); - } - else - { - if (syncshellBroadcasts.Count == 0) - { - ClearSyncshells(); - return; - } - - try - { - var groups = await _apiController.GetBroadcastedGroups(syncshellBroadcasts) - .ConfigureAwait(false); - updatedList = groups?.DistinctBy(g => g.Group.GID).ToList(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to refresh broadcasted syncshells."); - return; - } - } - - if (updatedList == null || updatedList.Count == 0) - { - ClearSyncshells(); - return; - } - - if (gid != null && _recentlyJoined.Contains(gid)) - { - _recentlyJoined.Clear(); - } - - var previousGid = GetSelectedGid(); - - _nearbySyncshells.Clear(); - _nearbySyncshells.AddRange(updatedList); - - if (previousGid != null) - { - var newIndex = _nearbySyncshells.FindIndex(s => - string.Equals(s.Group.GID, previousGid, StringComparison.Ordinal)); - - if (newIndex >= 0) - { - _selectedNearbyIndex = newIndex; - return; - } - } - - ClearSelection(); - } - - private static List BuildTestSyncshells() - { - var testGroup1 = new GroupData("TEST-ALPHA", "Alpha Shell"); - var testGroup2 = new GroupData("TEST-BETA", "Beta Shell"); - var testGroup3 = new GroupData("TEST-GAMMA", "Gamma Shell"); - var testGroup4 = new GroupData("TEST-DELTA", "Delta Shell"); - var testGroup5 = new GroupData("TEST-CHARLIE", "Charlie Shell"); - var testGroup6 = new GroupData("TEST-OMEGA", "Omega Shell"); - var testGroup7 = new GroupData("TEST-POINT", "Point Shell"); - var testGroup8 = new GroupData("TEST-HOTEL", "Hotel Shell"); - - return - [ - new(testGroup1, "", GroupUserPreferredPermissions.NoneSet), - new(testGroup2, "", GroupUserPreferredPermissions.NoneSet), - new(testGroup3, "", GroupUserPreferredPermissions.NoneSet), - new(testGroup4, "", GroupUserPreferredPermissions.NoneSet), - new(testGroup5, "", GroupUserPreferredPermissions.NoneSet), - new(testGroup6, "", GroupUserPreferredPermissions.NoneSet), - new(testGroup7, "", GroupUserPreferredPermissions.NoneSet), - new(testGroup8, "", GroupUserPreferredPermissions.NoneSet), - ]; - } - - private void ClearSyncshells() - { - if (_nearbySyncshells.Count == 0) - return; - - _nearbySyncshells.Clear(); - ClearSelection(); - } - - private void ClearSelection() - { - _selectedNearbyIndex = -1; - _syncshellPageIndex = 0; - _joinDto = null; - _joinInfo = null; - } - - private string? GetSelectedGid() - { - if (_selectedNearbyIndex < 0 || _selectedNearbyIndex >= _nearbySyncshells.Count) - return null; - - return _nearbySyncshells[_selectedNearbyIndex].Group.GID; - } - -} diff --git a/LightlessSync/UI/TopTabMenu.cs b/LightlessSync/UI/TopTabMenu.cs index cc69a5d..b63b631 100644 --- a/LightlessSync/UI/TopTabMenu.cs +++ b/LightlessSync/UI/TopTabMenu.cs @@ -162,24 +162,18 @@ public class TopTabMenu ImGui.SameLine(); using (ImRaii.PushFont(UiBuilder.IconFont)) { - var x = ImGui.GetCursorScreenPos(); if (ImGui.Button(FontAwesomeIcon.Compass.ToIconString(), buttonSize)) { - TabSelection = TabSelection == SelectedTab.Lightfinder ? SelectedTab.None : SelectedTab.Lightfinder; + _lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); } if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) { Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); } - ImGui.SameLine(); - var xAfter = ImGui.GetCursorScreenPos(); - if (TabSelection == SelectedTab.Lightfinder) - drawList.AddLine(x with { Y = x.Y + buttonSize.Y + spacing.Y }, - xAfter with { Y = xAfter.Y + buttonSize.Y + spacing.Y, X = xAfter.X - spacing.X }, - underlineColor, 2); + } - UiSharedService.AttachToolTip("Lightfinder"); + UiSharedService.AttachToolTip(GetLightfinderTooltip()); ImGui.SameLine(); using (ImRaii.PushFont(UiBuilder.IconFont)) @@ -234,10 +228,7 @@ public class TopTabMenu DrawSyncshellMenu(availableWidth, spacing.X); DrawGlobalSyncshellButtons(availableWidth, spacing.X); } - else if (TabSelection == SelectedTab.Lightfinder) - { - DrawLightfinderMenu(availableWidth, spacing.X); - } + else if (TabSelection == SelectedTab.UserConfig) { DrawUserConfig(availableWidth, spacing.X); @@ -779,26 +770,17 @@ public class TopTabMenu private void DrawLightfinderMenu(float availableWidth, float spacingX) { - var buttonX = (availableWidth - (spacingX)) / 2f; - - if (_uiSharedService.IconTextButton(FontAwesomeIcon.PersonCirclePlus, "Lightfinder", buttonX, center: true)) + var lightfinderLabel = GetLightfinderTooltip(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.PersonCirclePlus, lightfinderLabel, availableWidth, center: true)) { _lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); } - - ImGui.SameLine(); - - var syncshellFinderLabel = GetSyncshellFinderLabel(); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Globe, syncshellFinderLabel, buttonX, center: true)) - { - _lightlessMediator.Publish(new UiToggleMessage(typeof(SyncshellFinderUI))); - } } - private string GetSyncshellFinderLabel() + private string GetLightfinderTooltip() { if (!_lightFinderService.IsBroadcasting) - return "Syncshell Finder"; + return "Open Lightfinder"; string? myHashedCid = null; try @@ -820,7 +802,7 @@ public class TopTabMenu .Distinct(StringComparer.Ordinal) .Count(); - return nearbyCount > 0 ? $"Syncshell Finder ({nearbyCount})" : "Syncshell Finder"; + return nearbyCount > 0 ? $"Lightfinder ({nearbyCount} nearby)" : "Open Lightfinder"; } private void DrawUserConfig(float availableWidth, float spacingX) diff --git a/LightlessSync/WebAPI/Files/FileDownloadManager.cs b/LightlessSync/WebAPI/Files/FileDownloadManager.cs index 49dd868..de9bca7 100644 --- a/LightlessSync/WebAPI/Files/FileDownloadManager.cs +++ b/LightlessSync/WebAPI/Files/FileDownloadManager.cs @@ -861,9 +861,17 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase { if (downloadCt.IsCancellationRequested) throw; - var req = await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCheckQueueFullPath(downloadFileTransfer[0].DownloadUri, requestId), - downloadFileTransfer.Select(c => c.Hash).ToList(), downloadCt).ConfigureAwait(false); - req.EnsureSuccessStatusCode(); + try + { + var req = await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCheckQueueFullPath(downloadFileTransfer[0].DownloadUri, requestId), + downloadFileTransfer.Select(c => c.Hash).ToList(), downloadCt).ConfigureAwait(false); + req.EnsureSuccessStatusCode(); + } + catch (Exception ex) when (!downloadCt.IsCancellationRequested) + { + Logger.LogDebug(ex, "Transient error checking queue status for {requestId}, will retry", requestId); + } + localTimeoutCts.Dispose(); composite.Dispose(); localTimeoutCts = new();