Files
LightlessClient/LightlessSync/UI/ZoneChatUi.cs
2025-12-21 09:00:34 +09:00

2021 lines
78 KiB
C#

using System.Globalization;
using System.Numerics;
using LightlessSync.API.Data;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using LightlessSync.API.Dto.Chat;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.Services;
using LightlessSync.Services.Chat;
using LightlessSync.Services.LightFinder;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.UI.Services;
using LightlessSync.UI.Style;
using LightlessSync.Utils;
using LightlessSync.WebAPI;
using LightlessSync.WebAPI.SignalR.Utils;
using Microsoft.Extensions.Logging;
namespace LightlessSync.UI;
public sealed class ZoneChatUi : WindowMediatorSubscriberBase
{
private const string ChatDisabledStatus = "Chat services disabled";
private const string ZoneUnavailableStatus = "Zone chat is only available in major cities.";
private const string SettingsPopupId = "zone_chat_settings_popup";
private const string ReportPopupId = "Report Message##zone_chat_report_popup";
private const string ChannelDragPayloadId = "zone_chat_channel_drag";
private const float DefaultWindowOpacity = .97f;
private const float DefaultUnfocusedWindowOpacity = 0.6f;
private const float MinWindowOpacity = 0.05f;
private const float MaxWindowOpacity = 1f;
private const float MinChatFontScale = 0.75f;
private const float MaxChatFontScale = 1.5f;
private const float UnfocusedFadeOutSpeed = 0.22f;
private const float FocusFadeInSpeed = 2.0f;
private const int ReportReasonMaxLength = 500;
private const int ReportContextMaxLength = 1000;
private const int MaxChannelNoteTabLength = 25;
private readonly UiSharedService _uiSharedService;
private readonly ZoneChatService _zoneChatService;
private readonly PairUiService _pairUiService;
private readonly LightFinderService _lightFinderService;
private readonly LightlessProfileManager _profileManager;
private readonly ApiController _apiController;
private readonly ChatConfigService _chatConfigService;
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly DalamudUtilService _dalamudUtilService;
private readonly IUiBuilder _uiBuilder;
private readonly Dictionary<string, string> _draftMessages = new(StringComparer.Ordinal);
private readonly ImGuiWindowFlags _unpinnedWindowFlags;
private float _currentWindowOpacity = DefaultWindowOpacity;
private float _baseWindowOpacity = DefaultWindowOpacity;
private bool _isWindowPinned;
private bool _showRulesOverlay;
private bool _refocusChatInput;
private string? _refocusChatInputKey;
private bool _isWindowFocused = true;
private int _titleBarStylePopCount;
private string? _selectedChannelKey;
private bool _scrollToBottom = true;
private float? _pendingChannelScroll;
private float _channelScroll;
private float _channelScrollMax;
private readonly SeluneBrush _seluneBrush = new();
private ChatChannelSnapshot? _reportTargetChannel;
private ChatMessageEntry? _reportTargetMessage;
private string _reportReason = string.Empty;
private string _reportAdditionalContext = string.Empty;
private bool _reportPopupOpen;
private bool _reportPopupRequested;
private bool _reportSubmitting;
private string? _reportError;
private ChatReportResult? _reportSubmissionResult;
private string? _dragChannelKey;
private string? _dragHoverKey;
private bool _HideStateActive;
private bool _HideStateWasOpen;
public ZoneChatUi(
ILogger<ZoneChatUi> logger,
LightlessMediator mediator,
UiSharedService uiSharedService,
ZoneChatService zoneChatService,
PairUiService pairUiService,
LightFinderService lightFinderService,
LightlessProfileManager profileManager,
ChatConfigService chatConfigService,
ServerConfigurationManager serverConfigurationManager,
DalamudUtilService dalamudUtilService,
IUiBuilder uiBuilder,
ApiController apiController,
PerformanceCollectorService performanceCollectorService)
: base(logger, mediator, "Lightless Chat", performanceCollectorService)
{
_uiSharedService = uiSharedService;
_zoneChatService = zoneChatService;
_pairUiService = pairUiService;
_lightFinderService = lightFinderService;
_profileManager = profileManager;
_chatConfigService = chatConfigService;
_serverConfigurationManager = serverConfigurationManager;
_dalamudUtilService = dalamudUtilService;
_uiBuilder = uiBuilder;
_apiController = apiController;
_isWindowPinned = _chatConfigService.Current.IsWindowPinned;
_showRulesOverlay = _chatConfigService.Current.ShowRulesOverlayOnOpen;
if (_chatConfigService.Current.AutoOpenChatOnPluginLoad)
{
IsOpen = true;
}
_unpinnedWindowFlags = Flags;
RefreshWindowFlags();
ApplyUiVisibilitySettings();
Size = new Vector2(450, 420) * ImGuiHelpers.GlobalScale;
SizeCondition = ImGuiCond.FirstUseEver;
WindowBuilder.For(this)
.SetSizeConstraints(
new Vector2(320f, 260f) * ImGuiHelpers.GlobalScale,
new Vector2(900f, 900f) * ImGuiHelpers.GlobalScale)
.Apply();
Mediator.Subscribe<ChatChannelMessageAdded>(this, OnChatChannelMessageAdded);
Mediator.Subscribe<ChatChannelsUpdated>(this, _ => _scrollToBottom = true);
Mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, _ => UpdateHideState());
Mediator.Subscribe<CutsceneFrameworkUpdateMessage>(this, _ => UpdateHideState());
}
public override void PreDraw()
{
RefreshWindowFlags();
base.PreDraw();
var config = _chatConfigService.Current;
var baseOpacity = Math.Clamp(config.ChatWindowOpacity, MinWindowOpacity, MaxWindowOpacity);
_baseWindowOpacity = baseOpacity;
if (config.FadeWhenUnfocused)
{
var unfocusedOpacity = Math.Clamp(config.UnfocusedWindowOpacity, MinWindowOpacity, MaxWindowOpacity);
var targetOpacity = _isWindowFocused ? baseOpacity : Math.Min(baseOpacity, unfocusedOpacity);
var delta = ImGui.GetIO().DeltaTime;
var speed = _isWindowFocused ? FocusFadeInSpeed : UnfocusedFadeOutSpeed;
_currentWindowOpacity = MoveTowards(_currentWindowOpacity, targetOpacity, speed * delta);
}
else
{
_currentWindowOpacity = baseOpacity;
}
ImGui.SetNextWindowBgAlpha(_currentWindowOpacity);
PushTitleBarFadeColors(_currentWindowOpacity);
}
private void UpdateHideState()
{
ApplyUiVisibilitySettings();
var shouldHide = ShouldHide();
if (shouldHide)
{
_HideStateWasOpen |= IsOpen;
if (IsOpen)
{
IsOpen = false;
}
_HideStateActive = true;
}
else if (_HideStateActive)
{
if (_HideStateWasOpen)
{
IsOpen = true;
}
_HideStateActive = false;
_HideStateWasOpen = false;
}
}
private void ApplyUiVisibilitySettings()
{
var config = _chatConfigService.Current;
_uiBuilder.DisableAutomaticUiHide = config.ShowWhenUiHidden;
_uiBuilder.DisableCutsceneUiHide = config.ShowInCutscenes;
_uiBuilder.DisableGposeUiHide = config.ShowInGpose;
}
private bool ShouldHide()
{
var config = _chatConfigService.Current;
if (config.HideInCombat && _dalamudUtilService.IsInCombat)
{
return true;
}
if (config.HideInDuty && _dalamudUtilService.IsInDuty && !_dalamudUtilService.IsInFieldOperation)
{
return true;
}
return false;
}
protected override void DrawInternal()
{
if (_titleBarStylePopCount > 0)
{
ImGui.PopStyleColor(_titleBarStylePopCount);
_titleBarStylePopCount = 0;
}
var config = _chatConfigService.Current;
var isFocused = ImGui.IsWindowFocused(ImGuiFocusedFlags.RootAndChildWindows);
var isHovered = ImGui.IsWindowHovered(ImGuiHoveredFlags.RootAndChildWindows);
if (config.FadeWhenUnfocused && isHovered && !isFocused)
{
ImGui.SetWindowFocus();
}
_isWindowFocused = config.FadeWhenUnfocused ? (isFocused || isHovered) : isFocused;
var contentAlpha = 1f;
if (config.FadeWhenUnfocused)
{
var baseOpacity = MathF.Max(_baseWindowOpacity, 0.001f);
contentAlpha = Math.Clamp(_currentWindowOpacity / baseOpacity, 0f, 1f);
}
using var alpha = ImRaii.PushStyle(ImGuiStyleVar.Alpha, contentAlpha);
var drawList = ImGui.GetWindowDrawList();
var windowPos = ImGui.GetWindowPos();
var windowSize = ImGui.GetWindowSize();
using var selune = Selune.Begin(_seluneBrush, drawList, windowPos, windowSize);
var childBgColor = ImGui.GetStyle().Colors[(int)ImGuiCol.ChildBg];
childBgColor.W *= _baseWindowOpacity;
using var childBg = ImRaii.PushColor(ImGuiCol.ChildBg, childBgColor);
DrawConnectionControls();
var channels = _zoneChatService.GetChannelsSnapshot();
DrawReportPopup();
if (channels.Count == 0)
{
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3);
ImGui.TextWrapped("No chat channels available.");
ImGui.PopStyleColor();
}
else
{
EnsureSelectedChannel(channels);
CleanupDrafts(channels);
DrawChannelButtons(channels);
if (_selectedChannelKey is null)
{
selune.DrawHighlightOnly(ImGui.GetIO().DeltaTime);
return;
}
var activeChannel = channels.FirstOrDefault(channel => string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal));
if (activeChannel.Equals(default(ChatChannelSnapshot)))
{
activeChannel = channels[0];
_selectedChannelKey = activeChannel.Key;
}
_zoneChatService.SetActiveChannel(activeChannel.Key);
DrawHeader(activeChannel);
ImGui.Separator();
DrawMessageArea(activeChannel, _currentWindowOpacity);
ImGui.Separator();
DrawInput(activeChannel);
}
if (_showRulesOverlay)
{
DrawRulesOverlay();
}
selune.DrawHighlightOnly(ImGui.GetIO().DeltaTime);
}
private void PushTitleBarFadeColors(float opacity)
{
_titleBarStylePopCount = 0;
var alpha = Math.Clamp(opacity, 0f, 1f);
var colors = ImGui.GetStyle().Colors;
var titleBg = colors[(int)ImGuiCol.TitleBg];
var titleBgActive = colors[(int)ImGuiCol.TitleBgActive];
var titleBgCollapsed = colors[(int)ImGuiCol.TitleBgCollapsed];
ImGui.PushStyleColor(ImGuiCol.TitleBg, new Vector4(titleBg.X, titleBg.Y, titleBg.Z, titleBg.W * alpha));
ImGui.PushStyleColor(ImGuiCol.TitleBgActive, new Vector4(titleBgActive.X, titleBgActive.Y, titleBgActive.Z, titleBgActive.W * alpha));
ImGui.PushStyleColor(ImGuiCol.TitleBgCollapsed, new Vector4(titleBgCollapsed.X, titleBgCollapsed.Y, titleBgCollapsed.Z, titleBgCollapsed.W * alpha));
_titleBarStylePopCount = 3;
}
private void DrawHeader(ChatChannelSnapshot channel)
{
var prefix = channel.Type == ChatChannelType.Zone ? "Zone" : "Syncshell";
Vector4 color;
if (!channel.IsConnected)
{
color = UIColors.Get("DimRed");
}
else if (!channel.IsAvailable)
{
color = ImGuiColors.DalamudGrey3;
}
else
{
color = channel.Type == ChatChannelType.Zone ? UIColors.Get("LightlessPurple") : UIColors.Get("LightlessBlue");
}
ImGui.TextColored(color, $"{prefix}: {channel.DisplayName}");
if (channel.Type == ChatChannelType.Zone && channel.Descriptor.WorldId != 0)
{
ImGui.SameLine();
var worldId = channel.Descriptor.WorldId;
var worldName = _dalamudUtilService.WorldData.Value.TryGetValue(worldId, out var name) ? name : $"World #{worldId}";
ImGui.TextUnformatted(worldName);
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip($"World ID: {worldId}");
}
}
var showInlineStatus = string.Equals(channel.StatusText, ChatDisabledStatus, StringComparison.OrdinalIgnoreCase)
|| string.Equals(channel.StatusText, ZoneUnavailableStatus, StringComparison.OrdinalIgnoreCase);
if (showInlineStatus)
{
ImGui.SameLine();
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3);
ImGui.TextUnformatted($"| {channel.StatusText}");
ImGui.PopStyleColor();
}
else if (!string.IsNullOrEmpty(channel.StatusText))
{
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3);
ImGui.TextWrapped(channel.StatusText);
ImGui.PopStyleColor();
}
}
private void DrawMessageArea(ChatChannelSnapshot channel, float windowOpacity)
{
var available = ImGui.GetContentRegionAvail();
var inputHeight = ImGui.GetFrameHeightWithSpacing() + ImGui.GetStyle().ItemSpacing.Y;
var height = Math.Max(100f * ImGuiHelpers.GlobalScale, available.Y - inputHeight);
using var child = ImRaii.Child($"chat_messages_{channel.Key}", new Vector2(-1, height), false);
if (!child)
return;
var configuredFontScale = Math.Clamp(_chatConfigService.Current.ChatFontScale, MinChatFontScale, MaxChatFontScale);
var restoreFontScale = false;
if (Math.Abs(configuredFontScale - 1f) > 0.001f)
{
ImGui.SetWindowFontScale(configuredFontScale);
restoreFontScale = true;
}
var drawList = ImGui.GetWindowDrawList();
var windowPos = ImGui.GetWindowPos();
var windowSize = ImGui.GetWindowSize();
var gradientBottom = UIColors.Get("LightlessPurple");
var bottomAlpha = 0.12f * windowOpacity;
var bottomColorVec = new Vector4(gradientBottom.X, gradientBottom.Y, gradientBottom.Z, bottomAlpha);
var topColorVec = new Vector4(gradientBottom.X, gradientBottom.Y, gradientBottom.Z, 0.0f);
var bottomColor = ImGui.ColorConvertFloat4ToU32(bottomColorVec);
var topColor = ImGui.ColorConvertFloat4ToU32(topColorVec);
drawList.AddRectFilledMultiColor(
windowPos,
windowPos + windowSize,
topColor,
topColor,
bottomColor,
bottomColor);
var showTimestamps = _chatConfigService.Current.ShowMessageTimestamps;
if (channel.Messages.Count == 0)
{
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3);
ImGui.TextWrapped("Chat history will appear here when available.");
ImGui.PopStyleColor();
}
else
{
for (var i = 0; i < channel.Messages.Count; i++)
{
var message = channel.Messages[i];
ImGui.PushID(i);
if (message.IsSystem)
{
DrawSystemEntry(message);
ImGui.PopID();
continue;
}
if (message.Payload is not { } payload)
{
ImGui.PopID();
continue;
}
var timestampText = string.Empty;
if (showTimestamps)
{
timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] ";
}
var color = message.FromSelf ? UIColors.Get("LightlessBlue") : ImGuiColors.DalamudWhite;
ImGui.PushStyleColor(ImGuiCol.Text, color);
ImGui.TextWrapped($"{timestampText}{message.DisplayName}: {payload.Message}");
ImGui.PopStyleColor();
if (ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}"))
{
var contextLocalTimestamp = payload.SentAtUtc.ToLocalTime();
var contextTimestampText = contextLocalTimestamp.ToString("yyyy-MM-dd HH:mm:ss 'UTC'z", CultureInfo.InvariantCulture);
ImGui.TextDisabled(contextTimestampText);
ImGui.Separator();
var actionIndex = 0;
foreach (var action in GetContextMenuActions(channel, message))
{
DrawContextMenuAction(action, actionIndex++);
}
ImGui.EndPopup();
}
ImGui.PopID();
}
}
if (_scrollToBottom)
{
ImGui.SetScrollHereY(1f);
_scrollToBottom = false;
}
if (restoreFontScale)
{
ImGui.SetWindowFontScale(1f);
}
}
private void DrawInput(ChatChannelSnapshot channel)
{
const int MaxMessageLength = ZoneChatService.MaxOutgoingLength;
var canSend = channel.IsConnected && channel.IsAvailable;
_draftMessages.TryGetValue(channel.Key, out var draft);
draft ??= string.Empty;
var style = ImGui.GetStyle();
var sendButtonWidth = 100f * ImGuiHelpers.GlobalScale;
var counterWidth = ImGui.CalcTextSize($"{MaxMessageLength}/{MaxMessageLength}").X;
var reservedWidth = sendButtonWidth + counterWidth + style.ItemSpacing.X * 2f;
ImGui.SetNextItemWidth(-reservedWidth);
var inputId = $"##chat-input-{channel.Key}";
if (_refocusChatInput && string.Equals(_refocusChatInputKey, channel.Key, StringComparison.Ordinal))
{
ImGui.SetKeyboardFocusHere();
_refocusChatInput = false;
_refocusChatInputKey = null;
}
ImGui.InputText(inputId, ref draft, MaxMessageLength);
if (ImGui.IsItemActive() || ImGui.IsItemFocused())
{
var drawList = ImGui.GetWindowDrawList();
var itemMin = ImGui.GetItemRectMin();
var itemMax = ImGui.GetItemRectMax();
var highlight = UIColors.Get("LightlessPurple").WithAlpha(0.35f);
var highlightU32 = ImGui.ColorConvertFloat4ToU32(highlight);
drawList.AddRect(itemMin, itemMax, highlightU32, style.FrameRounding, ImDrawFlags.None, Math.Max(1f, ImGuiHelpers.GlobalScale));
}
var enterPressed = ImGui.IsItemFocused()
&& (ImGui.IsKeyPressed(ImGuiKey.Enter) || ImGui.IsKeyPressed(ImGuiKey.KeypadEnter));
_draftMessages[channel.Key] = draft;
ImGui.SameLine();
ImGui.AlignTextToFramePadding();
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3);
ImGui.TextUnformatted($"{draft.Length}/{MaxMessageLength}");
ImGui.PopStyleColor();
ImGui.SameLine();
var buttonScreenPos = ImGui.GetCursorScreenPos();
var rightEdgeScreen = ImGui.GetWindowPos().X + ImGui.GetWindowContentRegionMax().X;
var desiredButtonX = rightEdgeScreen - sendButtonWidth;
var minButtonX = buttonScreenPos.X + style.ItemSpacing.X;
var finalButtonX = MathF.Max(minButtonX, desiredButtonX);
ImGui.SetCursorScreenPos(new Vector2(finalButtonX, buttonScreenPos.Y));
var sendColor = UIColors.Get("LightlessPurpleDefault");
var sendHovered = UIColors.Get("LightlessPurple");
var sendActive = UIColors.Get("LightlessPurpleActive");
ImGui.PushStyleColor(ImGuiCol.Button, sendColor);
ImGui.PushStyleColor(ImGuiCol.ButtonHovered, sendHovered);
ImGui.PushStyleColor(ImGuiCol.ButtonActive, sendActive);
ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 6f * ImGuiHelpers.GlobalScale);
var sendClicked = false;
using (ImRaii.Disabled(!canSend))
{
if (_uiSharedService.IconTextButton(FontAwesomeIcon.PaperPlane, $"Send##chat-send-{channel.Key}", 100f * ImGuiHelpers.GlobalScale, center: true))
{
sendClicked = true;
}
}
ImGui.PopStyleVar();
ImGui.PopStyleColor(3);
if (canSend && (enterPressed || sendClicked))
{
_refocusChatInput = true;
_refocusChatInputKey = channel.Key;
if (TrySendDraft(channel, draft))
{
_draftMessages[channel.Key] = string.Empty;
_scrollToBottom = true;
}
}
}
private void DrawRulesOverlay()
{
var windowPos = ImGui.GetWindowPos();
var windowSize = ImGui.GetWindowSize();
var parentContentMin = ImGui.GetWindowContentRegionMin();
var parentContentMax = ImGui.GetWindowContentRegionMax();
var overlayPos = windowPos + parentContentMin;
var overlaySize = parentContentMax - parentContentMin;
if (overlaySize.X <= 0f || overlaySize.Y <= 0f)
{
overlayPos = windowPos;
overlaySize = windowSize;
}
ImGui.SetNextWindowFocus();
ImGui.SetNextWindowPos(overlayPos);
ImGui.SetNextWindowSize(overlaySize);
ImGui.SetNextWindowBgAlpha(0.86f);
ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * ImGuiHelpers.GlobalScale);
ImGui.PushStyleColor(ImGuiCol.Border, Vector4.Zero);
var overlayFlags = ImGuiWindowFlags.NoDecoration
| ImGuiWindowFlags.NoMove
| ImGuiWindowFlags.NoScrollbar
| ImGuiWindowFlags.NoSavedSettings;
var overlayOpen = true;
if (ImGui.Begin("##zone_chat_rules_overlay", ref overlayOpen, overlayFlags))
{
var contentMin = ImGui.GetWindowContentRegionMin();
var contentMax = ImGui.GetWindowContentRegionMax();
var contentWidth = contentMax.X - contentMin.X;
var title = "Chat Rules";
var titleWidth = ImGui.CalcTextSize(title).X;
ImGui.SetCursorPosX(contentMin.X + Math.Max(0f, (contentWidth - titleWidth) * 0.5f));
ImGui.TextUnformatted(title);
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
var style = ImGui.GetStyle();
var buttonWidth = 180f * ImGuiHelpers.GlobalScale;
var buttonHeight = ImGui.GetFrameHeight();
var buttonSpacing = Math.Max(0f, style.ItemSpacing.Y);
var buttonTopY = Math.Max(contentMin.Y, contentMax.Y - buttonHeight);
var childHeight = Math.Max(0f, buttonTopY - buttonSpacing - ImGui.GetCursorPosY());
using (var child = ImRaii.Child("zone_chat_rules_overlay_scroll", new Vector2(-1f, childHeight), false))
{
if (child)
{
var childContentMin = ImGui.GetWindowContentRegionMin();
var childContentMax = ImGui.GetWindowContentRegionMax();
var childContentWidth = childContentMax.X - childContentMin.X;
ImGui.PushTextWrapPos(childContentMin.X + childContentWidth);
_uiSharedService.MediumText("Basic Chat Rules", UIColors.Get("LightlessBlue"));
_uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"),
new SeStringUtils.RichTextEntry("Do "),
new SeStringUtils.RichTextEntry("NOT share", UIColors.Get("DimRed"), true),
new SeStringUtils.RichTextEntry(" confidential, personal, or account information-yours or anyone else's", UIColors.Get("LightlessYellow"), true),
new SeStringUtils.RichTextEntry("."));
_uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"),
new SeStringUtils.RichTextEntry("Respect ALL participants; "),
new SeStringUtils.RichTextEntry("no harassment, hate speech, or personal attacks", UIColors.Get("DimRed"), true),
new SeStringUtils.RichTextEntry("."));
_uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"),
new SeStringUtils.RichTextEntry("AVOID ", UIColors.Get("DimRed"), true),
new SeStringUtils.RichTextEntry("disruptive behaviors such as "),
new SeStringUtils.RichTextEntry("spamming or flooding", UIColors.Get("DimRed"), true),
new SeStringUtils.RichTextEntry("."));
_uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"),
new SeStringUtils.RichTextEntry("Absolutely "),
new SeStringUtils.RichTextEntry("NO discussion, sharing, or solicitation of illegal content or activities;", UIColors.Get("DimRed"), true),
new SeStringUtils.RichTextEntry("."));
ImGui.Dummy(new Vector2(5));
ImGui.Separator();
_uiSharedService.MediumText("Zone Chat Rules", UIColors.Get("LightlessGreen"));
_uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"),
new SeStringUtils.RichTextEntry("NO ADVERTISEMENTS", UIColors.Get("DimRed"), true),
new SeStringUtils.RichTextEntry(" whatsoever for any kind of "),
new SeStringUtils.RichTextEntry("services, venues, clubs, marketboard deals, or similar offerings", UIColors.Get("LightlessYellow"), true),
new SeStringUtils.RichTextEntry("."));
_uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"),
new SeStringUtils.RichTextEntry("Mature", UIColors.Get("DimRed"), true),
new SeStringUtils.RichTextEntry(" or "),
new SeStringUtils.RichTextEntry("NSFW", UIColors.Get("DimRed"), true),
new SeStringUtils.RichTextEntry(" content "),
new SeStringUtils.RichTextEntry("(including suggestive emotes, explicit innuendo, or roleplay (including requests) )", UIColors.Get("LightlessYellow"), true),
new SeStringUtils.RichTextEntry(" is"),
new SeStringUtils.RichTextEntry(" strictly prohibited ", UIColors.Get("DimRed"), true),
new SeStringUtils.RichTextEntry("in Zone Chat."));
ImGui.Dummy(new Vector2(5));
ImGui.Separator();
_uiSharedService.MediumText("Syncshell Chat Rules", UIColors.Get("LightlessYellow"));
_uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), new SeStringUtils.RichTextEntry("Syncshell chats are self-moderated (their own set rules) by it's owner and appointed moderators."));
ImGui.Dummy(new Vector2(5));
ImGui.Separator();
_uiSharedService.MediumText("Reporting & Punishments", UIColors.Get("LightlessBlue"));
_uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessBlue"),
new SeStringUtils.RichTextEntry("Report rule-breakers by right clicking on the sent message and clicking report or via the mod-mail channel on the Discord."),
new SeStringUtils.RichTextEntry(" (False reports may be punished.) ", UIColors.Get("DimRed"), true));
_uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessBlue"),
new SeStringUtils.RichTextEntry("Punishments scale from a permanent chat ban up to a full Lightless account ban."),
new SeStringUtils.RichTextEntry(" (Appeals are possible, but will be accepted only in clear cases of error.) ", UIColors.Get("DimRed"), true));
ImGui.PopTextWrapPos();
}
}
var spacingStartY = Math.Max(ImGui.GetCursorPosY(), buttonTopY - buttonSpacing);
ImGui.SetCursorPosY(spacingStartY);
if (buttonSpacing > 0f)
{
var actualSpacing = Math.Max(0f, buttonTopY - spacingStartY);
if (actualSpacing > 0f)
{
ImGui.Dummy(new Vector2(0f, actualSpacing));
}
}
ImGui.SetCursorPosY(buttonTopY);
var buttonX = contentMin.X + Math.Max(0f, (contentWidth - buttonWidth) * 0.5f);
ImGui.SetCursorPosX(buttonX);
if (ImGui.Button("I Understand", new Vector2(buttonWidth, buttonHeight)))
{
_showRulesOverlay = false;
}
if (!overlayOpen)
{
_showRulesOverlay = false;
}
}
ImGui.End();
ImGui.PopStyleColor();
ImGui.PopStyleVar();
}
private void DrawReportPopup()
{
if (!_reportPopupOpen)
return;
var desiredPopupSize = new Vector2(520f * ImGuiHelpers.GlobalScale, 0f);
ImGui.SetNextWindowSize(desiredPopupSize, ImGuiCond.Always);
if (_reportPopupRequested)
{
ImGui.OpenPopup(ReportPopupId);
_reportPopupRequested = false;
}
else if (!ImGui.IsPopupOpen(ReportPopupId, ImGuiPopupFlags.AnyPopupLevel))
{
ImGui.OpenPopup(ReportPopupId);
}
var popupFlags = UiSharedService.PopupWindowFlags | ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoSavedSettings;
if (!ImGui.BeginPopupModal(ReportPopupId, popupFlags))
return;
if (_reportTargetChannel is not { } channel || _reportTargetMessage is not { } message)
{
CloseReportPopup();
ImGui.EndPopup();
return;
}
if (message.Payload is not { } payload)
{
CloseReportPopup();
ImGui.EndPopup();
return;
}
if (_reportSubmissionResult is { } pendingResult)
{
_reportSubmissionResult = null;
_reportSubmitting = false;
if (pendingResult.Success)
{
Mediator.Publish(new NotificationMessage("Zone Chat", "Report submitted for moderator review.", NotificationType.Info, TimeSpan.FromSeconds(3)));
CloseReportPopup();
ImGui.EndPopup();
return;
}
_reportError = pendingResult.ErrorMessage ?? "Failed to submit report. Please try again.";
}
var channelPrefix = channel.Type == ChatChannelType.Zone ? "Zone" : "Syncshell";
var channelLabel = $"{channelPrefix}: {channel.DisplayName}";
if (channel.Type == ChatChannelType.Zone && channel.Descriptor.WorldId != 0)
{
channelLabel += $" (World #{channel.Descriptor.WorldId})";
}
ImGui.TextUnformatted(channelLabel);
ImGui.TextUnformatted($"Sender: {message.DisplayName}");
ImGui.TextUnformatted($"Sent: {payload.SentAtUtc.ToLocalTime().ToString("g", CultureInfo.CurrentCulture)}");
ImGui.Separator();
ImGui.PushTextWrapPos(ImGui.GetWindowContentRegionMax().X);
ImGui.TextWrapped(payload.Message);
ImGui.PopTextWrapPos();
ImGui.Separator();
ImGui.TextUnformatted("Reason (required)");
if (ImGui.InputTextMultiline("##chat_report_reason", ref _reportReason, ReportReasonMaxLength, new Vector2(-1, 80f * ImGuiHelpers.GlobalScale))
&& _reportReason.Length > ReportReasonMaxLength)
{
_reportReason = _reportReason[..ReportReasonMaxLength];
}
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3);
ImGui.TextUnformatted($"{_reportReason.Length}/{ReportReasonMaxLength}");
ImGui.PopStyleColor();
ImGui.Spacing();
ImGui.TextUnformatted("Additional context (optional)");
if (ImGui.InputTextMultiline("##chat_report_context", ref _reportAdditionalContext, ReportContextMaxLength, new Vector2(-1, 120f * ImGuiHelpers.GlobalScale))
&& _reportAdditionalContext.Length > ReportContextMaxLength)
{
_reportAdditionalContext = _reportAdditionalContext[..ReportContextMaxLength];
}
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3);
ImGui.TextUnformatted($"{_reportAdditionalContext.Length}/{ReportContextMaxLength}");
ImGui.PopStyleColor();
if (!string.IsNullOrEmpty(_reportError))
{
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed"));
ImGui.TextWrapped(_reportError);
ImGui.PopStyleColor();
}
if (_reportSubmitting)
{
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3);
ImGui.TextUnformatted("Submitting report...");
ImGui.PopStyleColor();
}
ImGui.Separator();
var style = ImGui.GetStyle();
var availableWidth = Math.Max(0f, ImGui.GetContentRegionAvail().X);
var buttonWidth = Math.Max(100f * ImGuiHelpers.GlobalScale, (availableWidth - style.ItemSpacing.X) / 2f);
var canSubmit = !_reportSubmitting && _reportReason.Trim().Length > 0;
using (ImRaii.Disabled(!canSubmit))
{
if (ImGui.Button("Submit Report", new Vector2(buttonWidth, 0f)) && canSubmit)
{
BeginReportSubmission(channel, message);
}
}
ImGui.SameLine();
if (ImGui.Button("Cancel", new Vector2(buttonWidth, 0f)))
{
CloseReportPopup();
}
ImGui.EndPopup();
}
private void OpenReportPopup(ChatChannelSnapshot channel, ChatMessageEntry message)
{
if (message.Payload is not { } payload)
{
_logger.LogDebug("Ignoring report popup request for non-message entry in {ChannelKey}", channel.Key);
return;
}
_reportTargetChannel = channel;
_reportTargetMessage = message;
_logger.LogDebug("Opening report popup for channel {ChannelKey}, message {MessageId}", channel.Key, payload.MessageId);
_reportReason = string.Empty;
_reportAdditionalContext = string.Empty;
_reportError = null;
_reportSubmissionResult = null;
_reportSubmitting = false;
_reportPopupOpen = true;
_reportPopupRequested = true;
}
private void BeginReportSubmission(ChatChannelSnapshot channel, ChatMessageEntry message)
{
if (_reportSubmitting)
return;
if (message.Payload is not { } payload)
{
_reportError = "Unable to report this message.";
return;
}
var trimmedReason = _reportReason.Trim();
if (trimmedReason.Length == 0)
{
_reportError = "Please describe the issue before submitting.";
return;
}
var trimmedContext = string.IsNullOrWhiteSpace(_reportAdditionalContext)
? null
: _reportAdditionalContext.Trim();
_reportSubmitting = true;
_reportError = null;
_reportSubmissionResult = null;
var descriptor = channel.Descriptor;
var messageId = payload.MessageId;
if (string.IsNullOrWhiteSpace(messageId))
{
_reportSubmitting = false;
_reportError = "Unable to report this message.";
_logger.LogWarning("Report submission aborted: missing message id for channel {ChannelKey}", channel.Key);
return;
}
_logger.LogDebug("Submitting chat report for channel {ChannelKey}, message {MessageId}", channel.Key, messageId);
_ = Task.Run(async () =>
{
ChatReportResult result;
try
{
result = await _zoneChatService.ReportMessageAsync(descriptor, messageId, trimmedReason, trimmedContext).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to submit chat report");
result = new ChatReportResult(false, "Failed to submit report. Please try again.");
}
_reportSubmissionResult = result;
if (result.Success)
{
_logger.LogInformation("Chat report submitted successfully for channel {ChannelKey}, message {MessageId}", channel.Key, messageId);
}
else
{
_logger.LogWarning("Chat report submission failed for channel {ChannelKey}, message {MessageId}: {Error}", channel.Key, messageId, result.ErrorMessage);
}
});
}
private void CloseReportPopup()
{
_reportPopupOpen = false;
_reportPopupRequested = false;
ResetReportPopupState();
ImGui.CloseCurrentPopup();
}
private void ResetReportPopupState()
{
_reportTargetChannel = null;
_reportTargetMessage = null;
_reportReason = string.Empty;
_reportAdditionalContext = string.Empty;
_reportError = null;
_reportSubmissionResult = null;
_reportSubmitting = false;
_reportPopupRequested = false;
}
private bool TrySendDraft(ChatChannelSnapshot channel, string draft)
{
var trimmed = draft.Trim();
if (trimmed.Length == 0)
return false;
bool succeeded;
try
{
succeeded = _zoneChatService.SendMessageAsync(channel.Descriptor, trimmed).GetAwaiter().GetResult();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send chat message");
succeeded = false;
}
return succeeded;
}
private IEnumerable<ChatMessageContextAction> GetContextMenuActions(ChatChannelSnapshot channel, ChatMessageEntry message)
{
if (message.IsSystem || message.Payload is not { } payload)
yield break;
if (TryCreateCopyMessageAction(message, payload, out var copyAction))
{
yield return copyAction;
}
if (TryCreateViewProfileAction(channel, message, payload, out var viewProfile))
{
yield return viewProfile;
}
if (TryCreateMuteParticipantAction(channel, message, payload, out var muteAction))
{
yield return muteAction;
}
if (TryCreateReportMessageAction(channel, message, payload, out var reportAction))
{
yield return reportAction;
}
}
private static bool TryCreateCopyMessageAction(ChatMessageEntry message, ChatMessageDto payload, out ChatMessageContextAction action)
{
var text = payload.Message;
if (string.IsNullOrEmpty(text))
{
action = default;
return false;
}
action = new ChatMessageContextAction(
FontAwesomeIcon.Clipboard,
"Copy Message",
true,
() => ImGui.SetClipboardText(text));
return true;
}
private bool TryCreateViewProfileAction(ChatChannelSnapshot channel, ChatMessageEntry message, ChatMessageDto payload, out ChatMessageContextAction action)
{
action = default;
switch (channel.Type)
{
case ChatChannelType.Group:
{
var user = payload.Sender.User;
if (user?.UID is not { Length: > 0 })
return false;
var snapshot = _pairUiService.GetSnapshot();
if (snapshot.PairsByUid.TryGetValue(user.UID, out var pair) && pair is not null)
{
action = new ChatMessageContextAction(
FontAwesomeIcon.User,
"View Profile",
true,
() => Mediator.Publish(new ProfileOpenStandaloneMessage(pair)));
return true;
}
action = new ChatMessageContextAction(
FontAwesomeIcon.User,
"View Profile",
true,
() => RunContextAction(() => OpenStandardProfileAsync(user)));
return true;
}
case ChatChannelType.Zone:
if (!payload.Sender.CanResolveProfile)
return false;
var hashedCid = payload.Sender.HashedCid;
if (string.IsNullOrEmpty(hashedCid))
return false;
action = new ChatMessageContextAction(
FontAwesomeIcon.User,
"View Profile",
true,
() => RunContextAction(() => OpenLightfinderProfileInternalAsync(hashedCid)));
return true;
default:
return false;
}
}
private bool TryCreateMuteParticipantAction(ChatChannelSnapshot channel, ChatMessageEntry message, ChatMessageDto payload, out ChatMessageContextAction action)
{
action = default;
if (message.FromSelf)
return false;
if (string.IsNullOrEmpty(payload.Sender.Token))
return false;
var safeName = string.IsNullOrWhiteSpace(message.DisplayName)
? "Participant"
: message.DisplayName;
action = new ChatMessageContextAction(
FontAwesomeIcon.VolumeMute,
$"Mute '{safeName}'",
true,
() => RunContextAction(() => _zoneChatService.SetParticipantMuteAsync(channel.Descriptor, payload.Sender.Token!, true)));
return true;
}
private bool TryCreateReportMessageAction(ChatChannelSnapshot channel, ChatMessageEntry message, ChatMessageDto payload, out ChatMessageContextAction action)
{
action = default;
if (message.FromSelf)
return false;
if (string.IsNullOrWhiteSpace(payload.MessageId))
return false;
action = new ChatMessageContextAction(
FontAwesomeIcon.ExclamationTriangle,
"Report Message",
true,
() => OpenReportPopup(channel, message));
return true;
}
private Task OpenStandardProfileAsync(UserData user)
{
_profileManager.GetLightlessProfile(user);
_profileManager.GetLightlessUserProfile(user);
Mediator.Publish(new OpenUserProfileMessage(user));
return Task.CompletedTask;
}
private void RunContextAction(Func<Task> action)
{
_ = Task.Run(async () =>
{
try
{
await action().ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Chat context action failed");
Mediator.Publish(new NotificationMessage("Zone Chat", "Action failed to complete.", NotificationType.Error, TimeSpan.FromSeconds(3)));
}
});
}
private void OnChatChannelMessageAdded(ChatChannelMessageAdded message)
{
if (_selectedChannelKey is not null && string.Equals(_selectedChannelKey, message.ChannelKey, StringComparison.Ordinal))
{
_scrollToBottom = true;
}
}
private async Task OpenLightfinderProfileInternalAsync(string hashedCid)
{
var profile = await _profileManager.GetLightfinderProfileAsync(hashedCid).ConfigureAwait(false);
if (profile is null)
{
Mediator.Publish(new NotificationMessage("Zone Chat", "Unable to load Lightfinder profile information.", NotificationType.Warning, TimeSpan.FromSeconds(3)));
return;
}
var sanitizedUser = profile.Value.User with
{
UID = "Lightfinder User",
Alias = "Lightfinder User"
};
Mediator.Publish(new OpenLightfinderProfileMessage(sanitizedUser, profile.Value.ProfileData, hashedCid));
}
private void EnsureSelectedChannel(IReadOnlyList<ChatChannelSnapshot> channels)
{
if (_selectedChannelKey is not null && channels.Any(channel => string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal)))
return;
_selectedChannelKey = channels.Count > 0 ? channels[0].Key : null;
if (_selectedChannelKey is not null)
{
_zoneChatService.SetActiveChannel(_selectedChannelKey);
_scrollToBottom = true;
}
}
private void CleanupDrafts(IReadOnlyList<ChatChannelSnapshot> channels)
{
var existingKeys = new HashSet<string>(channels.Select(c => c.Key), StringComparer.Ordinal);
foreach (var key in _draftMessages.Keys.ToList())
{
if (!existingKeys.Contains(key))
{
_draftMessages.Remove(key);
}
}
if (_refocusChatInputKey is not null && !existingKeys.Contains(_refocusChatInputKey))
{
_refocusChatInputKey = null;
_refocusChatInput = false;
}
}
private void DrawConnectionControls()
{
var hubState = _apiController.ServerState;
var chatEnabled = _zoneChatService.IsChatEnabled;
var chatConnected = _zoneChatService.IsChatConnected;
var buttonLabel = chatEnabled ? "Disable Chat" : "Enable Chat";
var style = ImGui.GetStyle();
var cursorStart = ImGui.GetCursorPos();
var contentRightX = cursorStart.X + ImGui.GetContentRegionAvail().X;
var rulesButtonWidth = 90f * ImGuiHelpers.GlobalScale;
using (ImRaii.Group())
{
if (ImGui.Button(buttonLabel, new Vector2(130f * ImGuiHelpers.GlobalScale, 0f)))
{
ToggleChatConnection(chatEnabled);
}
ImGui.SameLine();
var chatStatusText = chatEnabled
? (chatConnected ? "Chat: Connected" : "Chat: Waiting")
: "Chat: Disabled";
var statusColor = chatEnabled
? (chatConnected ? UIColors.Get("LightlessGreen") : UIColors.Get("LightlessYellow"))
: ImGuiColors.DalamudGrey3;
ImGui.PushStyleColor(ImGuiCol.Text, statusColor);
ImGui.TextUnformatted(chatStatusText);
ImGui.PopStyleColor();
if (!string.IsNullOrWhiteSpace(_apiController.AuthFailureMessage) && hubState == ServerState.Unauthorized)
{
ImGui.SameLine();
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed"));
ImGui.TextUnformatted(_apiController.AuthFailureMessage);
ImGui.PopStyleColor();
}
}
var groupSize = ImGui.GetItemRectSize();
var minBlockX = cursorStart.X + groupSize.X + style.ItemSpacing.X;
var availableAfterGroup = contentRightX - (cursorStart.X + groupSize.X);
var lightfinderButtonWidth = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.PersonCirclePlus).X;
var settingsButtonWidth = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Cog).X;
var pinIcon = _isWindowPinned ? FontAwesomeIcon.Lock : FontAwesomeIcon.Unlock;
var pinButtonWidth = _uiSharedService.GetIconButtonSize(pinIcon).X;
var blockWidth = lightfinderButtonWidth + style.ItemSpacing.X + rulesButtonWidth + style.ItemSpacing.X + settingsButtonWidth + style.ItemSpacing.X + pinButtonWidth;
var desiredBlockX = availableAfterGroup > blockWidth + style.ItemSpacing.X
? contentRightX - blockWidth
: minBlockX;
desiredBlockX = Math.Max(cursorStart.X, desiredBlockX);
var lightfinderPos = new Vector2(desiredBlockX, cursorStart.Y);
var rulesPos = new Vector2(lightfinderPos.X + lightfinderButtonWidth + style.ItemSpacing.X, cursorStart.Y);
var settingsPos = new Vector2(rulesPos.X + rulesButtonWidth + style.ItemSpacing.X, cursorStart.Y);
var pinPos = new Vector2(settingsPos.X + settingsButtonWidth + style.ItemSpacing.X, cursorStart.Y);
ImGui.SameLine();
ImGui.SetCursorPos(lightfinderPos);
var lightfinderEnabled = _lightFinderService.IsBroadcasting;
var lightfinderColor = lightfinderEnabled ? UIColors.Get("LightlessGreen") : ImGuiColors.DalamudGrey3;
var lightfinderButtonSize = new Vector2(lightfinderButtonWidth, ImGui.GetFrameHeight());
ImGui.InvisibleButton("zone_chat_lightfinder_button", lightfinderButtonSize);
var lightfinderMin = ImGui.GetItemRectMin();
var lightfinderMax = ImGui.GetItemRectMax();
var iconSize = _uiSharedService.GetIconSize(FontAwesomeIcon.PersonCirclePlus);
var iconPos = new Vector2(
lightfinderMin.X + (lightfinderButtonSize.X - iconSize.X) * 0.5f,
lightfinderMin.Y + (lightfinderButtonSize.Y - iconSize.Y) * 0.5f);
using (_uiSharedService.IconFont.Push())
{
ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(lightfinderColor), FontAwesomeIcon.PersonCirclePlus.ToIconString());
}
if (ImGui.IsItemClicked())
{
Mediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
}
if (ImGui.IsItemHovered())
{
var padding = new Vector2(8f * ImGuiHelpers.GlobalScale);
Selune.RegisterHighlight(
lightfinderMin - padding,
lightfinderMax + padding,
SeluneHighlightMode.Point,
exactSize: true,
clipToElement: true,
clipPadding: padding,
highlightColorOverride: lightfinderColor,
highlightAlphaOverride: 0.2f);
ImGui.SetTooltip("If Lightfinder is enabled, you will be able to see the character names of other Lightfinder users in the same zone when they send a message.");
}
ImGui.SameLine();
ImGui.SetCursorPos(rulesPos);
if (ImGui.Button("Rules", new Vector2(rulesButtonWidth, 0f)))
{
_showRulesOverlay = true;
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Show chat rules");
}
ImGui.SameLine();
ImGui.SetCursorPos(settingsPos);
if (_uiSharedService.IconButton(FontAwesomeIcon.Cog))
{
ImGui.OpenPopup(SettingsPopupId);
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Chat settings");
}
ImGui.SameLine();
ImGui.SetCursorPos(pinPos);
using (ImRaii.PushId("window_pin_button"))
{
var restorePinColors = false;
if (_isWindowPinned)
{
var pinBase = UIColors.Get("LightlessPurpleDefault");
var pinHover = UIColors.Get("LightlessPurple").WithAlpha(0.9f);
var pinActive = UIColors.Get("LightlessPurpleActive");
ImGui.PushStyleColor(ImGuiCol.Button, pinBase);
ImGui.PushStyleColor(ImGuiCol.ButtonHovered, pinHover);
ImGui.PushStyleColor(ImGuiCol.ButtonActive, pinActive);
restorePinColors = true;
}
var pinClicked = _uiSharedService.IconButton(pinIcon);
if (restorePinColors)
{
ImGui.PopStyleColor(3);
}
if (pinClicked)
{
ToggleWindowPinned();
}
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip(_isWindowPinned ? "Unpin window" : "Pin window");
}
DrawChatSettingsPopup();
ImGui.Separator();
}
private void ToggleWindowPinned()
{
_isWindowPinned = !_isWindowPinned;
_chatConfigService.Current.IsWindowPinned = _isWindowPinned;
_chatConfigService.Save();
RefreshWindowFlags();
}
private void RefreshWindowFlags()
{
Flags = _unpinnedWindowFlags & ~ImGuiWindowFlags.NoCollapse;
if (_isWindowPinned)
{
Flags |= ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoResize;
}
}
private void DrawChatSettingsPopup()
{
const ImGuiWindowFlags popupFlags = ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoSavedSettings;
if (!ImGui.BeginPopup(SettingsPopupId, popupFlags))
return;
ImGui.TextUnformatted("Chat Settings");
ImGui.Separator();
var chatConfig = _chatConfigService.Current;
var autoEnable = chatConfig.AutoEnableChatOnLogin;
if (ImGui.Checkbox("Auto-enable chat on login", ref autoEnable))
{
chatConfig.AutoEnableChatOnLogin = autoEnable;
_chatConfigService.Save();
if (autoEnable && !_zoneChatService.IsChatEnabled)
{
ToggleChatConnection(currentlyEnabled: false);
}
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Automatically connect to chat whenever Lightless loads.");
}
var autoOpen = chatConfig.AutoOpenChatOnPluginLoad;
if (ImGui.Checkbox("Auto-open chat window on plugin load", ref autoOpen))
{
chatConfig.AutoOpenChatOnPluginLoad = autoOpen;
_chatConfigService.Save();
if (autoOpen)
{
IsOpen = true;
}
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Opens the chat window automatically whenever the plugin loads.");
}
var showRules = chatConfig.ShowRulesOverlayOnOpen;
if (ImGui.Checkbox("Show rules overlay on open", ref showRules))
{
chatConfig.ShowRulesOverlayOnOpen = showRules;
_chatConfigService.Save();
_showRulesOverlay = showRules;
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Toggles if the rules popup appears everytime the chat is opened for the first time.");
}
var showTimestamps = chatConfig.ShowMessageTimestamps;
if (ImGui.Checkbox("Show message timestamps", ref showTimestamps))
{
chatConfig.ShowMessageTimestamps = showTimestamps;
_chatConfigService.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Toggles the timestamp prefix on messages.");
}
ImGui.Separator();
ImGui.TextUnformatted("Chat Visibility");
var autoHideCombat = chatConfig.HideInCombat;
if (ImGui.Checkbox("Hide in combat", ref autoHideCombat))
{
chatConfig.HideInCombat = autoHideCombat;
_chatConfigService.Save();
UpdateHideState();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Temporarily hides the chat window while in combat.");
}
var autoHideDuty = chatConfig.HideInDuty;
if (ImGui.Checkbox("Hide in duty (Not in field operations)", ref autoHideDuty))
{
chatConfig.HideInDuty = autoHideDuty;
_chatConfigService.Save();
UpdateHideState();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Hides the chat window inside duties.");
}
var showWhenUiHidden = chatConfig.ShowWhenUiHidden;
if (ImGui.Checkbox("Show when game UI is hidden", ref showWhenUiHidden))
{
chatConfig.ShowWhenUiHidden = showWhenUiHidden;
_chatConfigService.Save();
UpdateHideState();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Allow the chat window to remain visible when the game UI is hidden.");
}
var showInCutscenes = chatConfig.ShowInCutscenes;
if (ImGui.Checkbox("Show in cutscenes", ref showInCutscenes))
{
chatConfig.ShowInCutscenes = showInCutscenes;
_chatConfigService.Save();
UpdateHideState();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Allow the chat window to remain visible during cutscenes.");
}
var showInGpose = chatConfig.ShowInGpose;
if (ImGui.Checkbox("Show in group pose", ref showInGpose))
{
chatConfig.ShowInGpose = showInGpose;
_chatConfigService.Save();
UpdateHideState();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Allow the chat window to remain visible in /gpose.");
}
ImGui.Separator();
var fontScale = Math.Clamp(chatConfig.ChatFontScale, MinChatFontScale, MaxChatFontScale);
var fontScaleChanged = ImGui.SliderFloat("Message font scale", ref fontScale, MinChatFontScale, MaxChatFontScale, "%.2fx");
var resetFontScale = ImGui.IsItemClicked(ImGuiMouseButton.Right);
if (resetFontScale)
{
fontScale = 1.0f;
fontScaleChanged = true;
}
if (fontScaleChanged)
{
chatConfig.ChatFontScale = fontScale;
_chatConfigService.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Adjust scale of chat message text.\nRight-click to reset to default.");
}
var windowOpacity = Math.Clamp(chatConfig.ChatWindowOpacity, MinWindowOpacity, MaxWindowOpacity);
var opacityChanged = ImGui.SliderFloat("Window transparency", ref windowOpacity, MinWindowOpacity, MaxWindowOpacity, "%.2f");
var resetOpacity = ImGui.IsItemClicked(ImGuiMouseButton.Right);
if (resetOpacity)
{
windowOpacity = DefaultWindowOpacity;
opacityChanged = true;
}
if (opacityChanged)
{
chatConfig.ChatWindowOpacity = windowOpacity;
_chatConfigService.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Adjust chat window transparency.\nRight-click to reset to default.");
}
var fadeUnfocused = chatConfig.FadeWhenUnfocused;
if (ImGui.Checkbox("Fade window when unfocused", ref fadeUnfocused))
{
chatConfig.FadeWhenUnfocused = fadeUnfocused;
_chatConfigService.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("When enabled, the chat window fades after it loses focus.\nHovering the window restores focus.");
}
ImGui.BeginDisabled(!fadeUnfocused);
var unfocusedOpacity = Math.Clamp(chatConfig.UnfocusedWindowOpacity, MinWindowOpacity, MaxWindowOpacity);
var unfocusedChanged = ImGui.SliderFloat("Unfocused transparency", ref unfocusedOpacity, MinWindowOpacity, MaxWindowOpacity, "%.2f");
var resetUnfocused = ImGui.IsItemClicked(ImGuiMouseButton.Right);
if (resetUnfocused)
{
unfocusedOpacity = DefaultUnfocusedWindowOpacity;
unfocusedChanged = true;
}
if (unfocusedChanged)
{
chatConfig.UnfocusedWindowOpacity = unfocusedOpacity;
_chatConfigService.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Target transparency while the chat window is unfocused.\nRight-click to reset to default.");
}
ImGui.EndDisabled();
ImGui.EndPopup();
}
private static float MoveTowards(float current, float target, float maxDelta)
{
if (current < target)
{
return MathF.Min(current + maxDelta, target);
}
if (current > target)
{
return MathF.Max(current - maxDelta, target);
}
return target;
}
private void ToggleChatConnection(bool currentlyEnabled)
{
_ = Task.Run(async () =>
{
try
{
await _zoneChatService.SetChatEnabledAsync(!currentlyEnabled).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to toggle chat connection");
}
});
}
private unsafe void DrawChannelButtons(IReadOnlyList<ChatChannelSnapshot> channels)
{
var style = ImGui.GetStyle();
var baseFramePadding = style.FramePadding;
var available = ImGui.GetContentRegionAvail().X;
var buttonHeight = ImGui.GetFrameHeight();
var arrowWidth = buttonHeight;
var hasChannels = channels.Count > 0;
var scrollWidth = hasChannels ? Math.Max(0f, available - (arrowWidth * 2f + style.ItemSpacing.X * 2f)) : 0f;
if (hasChannels)
{
var minimumWidth = 120f * ImGuiHelpers.GlobalScale;
scrollWidth = Math.Max(scrollWidth, minimumWidth);
}
var scrollStep = scrollWidth > 0f ? scrollWidth * 0.9f : 120f;
if (!hasChannels)
{
_pendingChannelScroll = null;
_channelScroll = 0f;
_channelScrollMax = 0f;
}
var prevScroll = hasChannels ? _channelScroll : 0f;
var prevMax = hasChannels ? _channelScrollMax : 0f;
float currentScroll = prevScroll;
float maxScroll = prevMax;
ImGui.PushID("chat_channel_buttons");
ImGui.BeginGroup();
using (ImRaii.Disabled(!hasChannels || prevScroll <= 0.5f))
{
var arrowNormal = UIColors.Get("ButtonDefault");
var arrowHovered = UIColors.Get("LightlessPurple").WithAlpha(0.85f);
var arrowActive = UIColors.Get("LightlessPurpleDefault").WithAlpha(0.75f);
ImGui.PushStyleColor(ImGuiCol.Button, arrowNormal);
ImGui.PushStyleColor(ImGuiCol.ButtonHovered, arrowHovered);
ImGui.PushStyleColor(ImGuiCol.ButtonActive, arrowActive);
var clickedLeft = ImGui.ArrowButton("##chat_left", ImGuiDir.Left);
ImGui.PopStyleColor(3);
if (clickedLeft)
{
_pendingChannelScroll = Math.Max(0f, currentScroll - scrollStep);
}
}
ImGui.SameLine(0f, style.ItemSpacing.X);
var childHeight = buttonHeight + style.FramePadding.Y * 2f + style.ScrollbarSize;
var alignPushed = false;
if (hasChannels)
{
ImGui.PushStyleVar(ImGuiStyleVar.ButtonTextAlign, new Vector2(0f, 0.5f));
alignPushed = true;
}
const int MaxBadgeDisplay = 99;
using (var child = ImRaii.Child("channel_scroll", new Vector2(scrollWidth, childHeight), false, ImGuiWindowFlags.HorizontalScrollbar))
{
if (child)
{
var dragActive = _dragChannelKey is not null && ImGui.IsMouseDragging(ImGuiMouseButton.Left);
var hoveredTargetThisFrame = false;
var first = true;
foreach (var channel in channels)
{
if (!first)
ImGui.SameLine();
var isSelected = string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal);
var showBadge = !isSelected && channel.UnreadCount > 0;
var isZoneChannel = channel.Type == ChatChannelType.Zone;
(string Text, Vector2 TextSize, float Width, float Height)? badgeMetrics = null;
var channelLabel = GetChannelTabLabel(channel);
var normal = isSelected ? UIColors.Get("LightlessPurpleDefault") : UIColors.Get("ButtonDefault");
var hovered = isSelected
? UIColors.Get("LightlessPurple").WithAlpha(0.9f)
: UIColors.Get("ButtonDefault").WithAlpha(0.85f);
var active = isSelected
? UIColors.Get("LightlessPurpleDefault").WithAlpha(0.8f)
: UIColors.Get("ButtonDefault").WithAlpha(0.75f);
ImGui.PushStyleColor(ImGuiCol.Button, normal);
ImGui.PushStyleColor(ImGuiCol.ButtonHovered, hovered);
ImGui.PushStyleColor(ImGuiCol.ButtonActive, active);
if (showBadge)
{
var badgeSpacing = 4f * ImGuiHelpers.GlobalScale;
var badgePadding = new Vector2(4f, 1.5f) * ImGuiHelpers.GlobalScale;
var badgeText = channel.UnreadCount > MaxBadgeDisplay
? $"{MaxBadgeDisplay}+"
: channel.UnreadCount.ToString(CultureInfo.InvariantCulture);
var badgeTextSize = ImGui.CalcTextSize(badgeText);
var badgeWidth = badgeTextSize.X + badgePadding.X * 2f;
var badgeHeight = badgeTextSize.Y + badgePadding.Y * 2f;
var customPadding = new Vector2(baseFramePadding.X + badgeWidth + badgeSpacing, baseFramePadding.Y);
ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, customPadding);
badgeMetrics = (badgeText, badgeTextSize, badgeWidth, badgeHeight);
}
var clicked = ImGui.Button($"{channelLabel}##chat_channel_{channel.Key}");
if (showBadge)
{
ImGui.PopStyleVar();
}
ImGui.PopStyleColor(3);
if (clicked && !isSelected)
{
_selectedChannelKey = channel.Key;
_zoneChatService.SetActiveChannel(channel.Key);
_scrollToBottom = true;
}
if (ShouldShowChannelTabContextMenu(channel)
&& ImGui.BeginPopupContextItem($"chat_channel_ctx##{channel.Key}"))
{
DrawChannelTabContextMenu(channel);
ImGui.EndPopup();
}
if (ImGui.BeginDragDropSource(ImGuiDragDropFlags.None))
{
if (!string.Equals(_dragChannelKey, channel.Key, StringComparison.Ordinal))
{
_dragHoverKey = null;
}
_dragChannelKey = channel.Key;
ImGui.SetDragDropPayload(ChannelDragPayloadId, null, 0);
ImGui.TextUnformatted(channelLabel);
ImGui.EndDragDropSource();
}
var isDragTarget = false;
if (ImGui.BeginDragDropTarget())
{
var acceptFlags = ImGuiDragDropFlags.AcceptBeforeDelivery | ImGuiDragDropFlags.AcceptNoDrawDefaultRect;
var payload = ImGui.AcceptDragDropPayload(ChannelDragPayloadId, acceptFlags);
if (!payload.IsNull && _dragChannelKey is { } draggedKey
&& !string.Equals(draggedKey, channel.Key, StringComparison.Ordinal))
{
isDragTarget = true;
if (!string.Equals(_dragHoverKey, channel.Key, StringComparison.Ordinal))
{
_dragHoverKey = channel.Key;
_zoneChatService.MoveChannel(draggedKey, channel.Key);
}
}
ImGui.EndDragDropTarget();
}
var isHoveredDuringDrag = dragActive
&& ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem | ImGuiHoveredFlags.AllowWhenOverlapped);
if (!isDragTarget && isHoveredDuringDrag
&& !string.Equals(_dragChannelKey, channel.Key, StringComparison.Ordinal))
{
isDragTarget = true;
if (!string.Equals(_dragHoverKey, channel.Key, StringComparison.Ordinal))
{
_dragHoverKey = channel.Key;
_zoneChatService.MoveChannel(_dragChannelKey!, channel.Key);
}
}
var drawList = ImGui.GetWindowDrawList();
var itemMin = ImGui.GetItemRectMin();
var itemMax = ImGui.GetItemRectMax();
if (isHoveredDuringDrag)
{
var highlight = UIColors.Get("LightlessPurple").WithAlpha(0.35f);
var highlightU32 = ImGui.ColorConvertFloat4ToU32(highlight);
drawList.AddRectFilled(itemMin, itemMax, highlightU32, style.FrameRounding);
drawList.AddRect(itemMin, itemMax, highlightU32, style.FrameRounding, ImDrawFlags.None, Math.Max(1f, ImGuiHelpers.GlobalScale));
}
if (isDragTarget)
{
hoveredTargetThisFrame = true;
}
if (isZoneChannel)
{
var borderColor = UIColors.Get("LightlessOrange");
var borderColorU32 = ImGui.ColorConvertFloat4ToU32(borderColor);
var borderThickness = Math.Max(1f, ImGuiHelpers.GlobalScale);
drawList.AddRect(itemMin, itemMax, borderColorU32, style.FrameRounding, ImDrawFlags.None, borderThickness);
}
if (showBadge && badgeMetrics is { } metrics)
{
var buttonSizeY = itemMax.Y - itemMin.Y;
var badgeMin = new Vector2(
itemMin.X + baseFramePadding.X,
itemMin.Y + (buttonSizeY - metrics.Height) * 0.5f);
var badgeMax = badgeMin + new Vector2(metrics.Width, metrics.Height);
var badgeColor = UIColors.Get("DimRed");
var badgeColorU32 = ImGui.ColorConvertFloat4ToU32(badgeColor);
drawList.AddRectFilled(badgeMin, badgeMax, badgeColorU32, metrics.Height * 0.5f);
var textPos = new Vector2(
badgeMin.X + (metrics.Width - metrics.TextSize.X) * 0.5f,
badgeMin.Y + (metrics.Height - metrics.TextSize.Y) * 0.5f);
drawList.AddText(textPos, ImGui.ColorConvertFloat4ToU32(ImGuiColors.DalamudWhite), metrics.Text);
}
first = false;
}
if (dragActive && !hoveredTargetThisFrame)
{
_dragHoverKey = null;
}
if (_pendingChannelScroll.HasValue)
{
ImGui.SetScrollX(_pendingChannelScroll.Value);
_pendingChannelScroll = null;
}
currentScroll = ImGui.GetScrollX();
maxScroll = ImGui.GetScrollMaxX();
}
}
if (alignPushed)
{
ImGui.PopStyleVar();
}
ImGui.SameLine(0f, style.ItemSpacing.X);
using (ImRaii.Disabled(!hasChannels || prevScroll >= prevMax - 0.5f))
{
var arrowNormal = UIColors.Get("ButtonDefault");
var arrowHovered = UIColors.Get("LightlessPurple").WithAlpha(0.85f);
var arrowActive = UIColors.Get("LightlessPurpleDefault").WithAlpha(0.75f);
ImGui.PushStyleColor(ImGuiCol.Button, arrowNormal);
ImGui.PushStyleColor(ImGuiCol.ButtonHovered, arrowHovered);
ImGui.PushStyleColor(ImGuiCol.ButtonActive, arrowActive);
var clickedRight = ImGui.ArrowButton("##chat_right", ImGuiDir.Right);
ImGui.PopStyleColor(3);
if (clickedRight)
{
_pendingChannelScroll = Math.Min(prevScroll + scrollStep, prevMax);
}
}
ImGui.EndGroup();
ImGui.PopID();
_channelScroll = currentScroll;
_channelScrollMax = maxScroll;
if (_dragChannelKey is not null && !ImGui.IsMouseDown(ImGuiMouseButton.Left))
{
_dragChannelKey = null;
_dragHoverKey = null;
}
ImGui.SetCursorPosY(ImGui.GetCursorPosY() - style.ItemSpacing.Y * 0.3f);
}
private string GetChannelTabLabel(ChatChannelSnapshot channel)
{
if (channel.Type != ChatChannelType.Group)
{
return channel.DisplayName;
}
if (!_chatConfigService.Current.PreferNotesForChannels.TryGetValue(channel.Key, out var preferNote) || !preferNote)
{
return channel.DisplayName;
}
var note = GetChannelNote(channel);
if (string.IsNullOrWhiteSpace(note))
{
return channel.DisplayName;
}
return TruncateChannelNoteForTab(note);
}
private static string TruncateChannelNoteForTab(string note)
{
if (note.Length <= MaxChannelNoteTabLength)
{
return note;
}
var ellipsis = "...";
var maxPrefix = Math.Max(0, MaxChannelNoteTabLength - ellipsis.Length);
return note[..maxPrefix] + ellipsis;
}
private bool ShouldShowChannelTabContextMenu(ChatChannelSnapshot channel)
{
if (channel.Type != ChatChannelType.Group)
{
return false;
}
if (_chatConfigService.Current.PreferNotesForChannels.TryGetValue(channel.Key, out var preferNote) && preferNote)
{
return true;
}
var note = GetChannelNote(channel);
return !string.IsNullOrWhiteSpace(note);
}
private void DrawChannelTabContextMenu(ChatChannelSnapshot channel)
{
var preferNote = _chatConfigService.Current.PreferNotesForChannels.TryGetValue(channel.Key, out var value) && value;
var note = GetChannelNote(channel);
var hasNote = !string.IsNullOrWhiteSpace(note);
if (preferNote || hasNote)
{
var label = preferNote ? "Prefer Name Instead" : "Prefer Note Instead";
if (ImGui.MenuItem(label))
{
SetPreferNoteForChannel(channel.Key, !preferNote);
}
}
if (preferNote)
{
ImGui.Separator();
ImGui.TextDisabled("Name:");
ImGui.TextWrapped(channel.DisplayName);
}
if (hasNote)
{
ImGui.Separator();
ImGui.TextDisabled("Note:");
ImGui.TextWrapped(note);
}
}
private string? GetChannelNote(ChatChannelSnapshot channel)
{
if (channel.Type != ChatChannelType.Group)
{
return null;
}
var gid = channel.Descriptor.CustomKey;
if (string.IsNullOrWhiteSpace(gid))
{
return null;
}
return _serverConfigurationManager.GetNoteForGid(gid);
}
private void SetPreferNoteForChannel(string channelKey, bool preferNote)
{
if (preferNote)
{
_chatConfigService.Current.PreferNotesForChannels[channelKey] = true;
}
else
{
_chatConfigService.Current.PreferNotesForChannels.Remove(channelKey);
}
_chatConfigService.Save();
}
private void DrawSystemEntry(ChatMessageEntry entry)
{
var system = entry.SystemMessage;
if (system is null)
return;
switch (system.Type)
{
case ChatSystemEntryType.ZoneSeparator:
DrawZoneSeparatorEntry(system, entry.ReceivedAtUtc);
break;
}
}
private void DrawZoneSeparatorEntry(ChatSystemEntry systemEntry, DateTime timestampUtc)
{
ImGui.Spacing();
var zoneName = string.IsNullOrWhiteSpace(systemEntry.ZoneName) ? "Zone" : systemEntry.ZoneName;
var localTime = timestampUtc.ToLocalTime();
var label = $"{localTime.ToString("HH:mm", CultureInfo.CurrentCulture)} - {zoneName}";
var availableWidth = ImGui.GetContentRegionAvail().X;
var textSize = ImGui.CalcTextSize(label);
var cursor = ImGui.GetCursorPos();
var textPosX = cursor.X + MathF.Max(0f, (availableWidth - textSize.X) * 0.5f);
ImGui.SetCursorPos(new Vector2(textPosX, cursor.Y));
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey2);
ImGui.TextUnformatted(label);
ImGui.PopStyleColor();
var nextY = ImGui.GetCursorPosY() + ImGui.GetStyle().ItemSpacing.Y * 0.35f;
ImGui.SetCursorPos(new Vector2(cursor.X, nextY));
ImGui.Separator();
ImGui.Spacing();
}
private void DrawContextMenuAction(ChatMessageContextAction action, int index)
{
ImGui.PushID(index);
using var disabled = ImRaii.Disabled(!action.IsEnabled);
var availableWidth = Math.Max(1f, ImGui.GetContentRegionAvail().X);
var clicked = ImGui.Selectable("##chat_ctx_action", false, ImGuiSelectableFlags.None, new Vector2(availableWidth, 0f));
var drawList = ImGui.GetWindowDrawList();
var itemMin = ImGui.GetItemRectMin();
var itemMax = ImGui.GetItemRectMax();
var itemHeight = itemMax.Y - itemMin.Y;
var style = ImGui.GetStyle();
var textColor = ImGui.GetColorU32(action.IsEnabled ? ImGuiCol.Text : ImGuiCol.TextDisabled);
var textSize = ImGui.CalcTextSize(action.Label);
var textPos = new Vector2(itemMin.X + style.FramePadding.X, itemMin.Y + (itemHeight - textSize.Y) * 0.5f);
if (action.Icon.HasValue)
{
var iconSize = _uiSharedService.GetIconSize(action.Icon.Value);
var iconPos = new Vector2(
itemMin.X + style.FramePadding.X,
itemMin.Y + (itemHeight - iconSize.Y) * 0.5f);
using (_uiSharedService.IconFont.Push())
{
drawList.AddText(iconPos, textColor, action.Icon.Value.ToIconString());
}
textPos.X = iconPos.X + iconSize.X + style.ItemSpacing.X;
}
drawList.AddText(textPos, textColor, action.Label);
if (clicked && action.IsEnabled)
{
ImGui.CloseCurrentPopup();
action.Execute();
}
ImGui.PopID();
}
private readonly record struct ChatMessageContextAction(FontAwesomeIcon? Icon, string Label, bool IsEnabled, Action Execute);
}