Files
LightlessClient/LightlessSync/UI/ZoneChatUi.cs

3022 lines
111 KiB
C#

using System.Globalization;
using System.Numerics;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Data.Enum;
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.API.Dto.Group;
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.Models;
using LightlessSync.UI.Services;
using LightlessSync.UI.Style;
using LightlessSync.Utils;
using Dalamud.Interface.Textures.TextureWraps;
using LightlessSync.WebAPI;
using LightlessSync.WebAPI.SignalR.Utils;
using Microsoft.Extensions.Logging;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Pairs;
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 string EmotePickerPopupId = "zone_chat_emote_picker";
private const int EmotePickerColumns = 10;
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 PairFactory _pairFactory;
private readonly ChatEmoteService _chatEmoteService;
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 Dictionary<string, List<string>> _pendingDraftClears = 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 _openEmotePicker;
private string _emoteFilter = string.Empty;
private bool _HideStateActive;
private bool _HideStateWasOpen;
private bool _pushedStyle;
public ZoneChatUi(
ILogger<ZoneChatUi> logger,
LightlessMediator mediator,
UiSharedService uiSharedService,
ZoneChatService zoneChatService,
PairUiService pairUiService,
PairFactory pairFactory,
ChatEmoteService chatEmoteService,
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;
_pairFactory = pairFactory;
_chatEmoteService = chatEmoteService;
_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;
ImGui.PushStyleVar(ImGuiStyleVar.WindowBorderSize, 0);
_pushedStyle = true;
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()
{
_uiBuilder.DisableUserUiHide = true;
_uiBuilder.DisableCutsceneUiHide = true;
}
private bool ShouldHide()
{
var config = _chatConfigService.Current;
if (!config.ShowWhenUiHidden && _dalamudUtilService.IsGameUiHidden)
{
return true;
}
if (!config.ShowInGpose && _dalamudUtilService.IsInGpose)
{
return true;
}
if (!config.ShowInCutscenes && _dalamudUtilService.IsInCutscene)
{
return true;
}
if (config.HideInCombat && _dalamudUtilService.IsInCombat)
{
return true;
}
if (config.HideInDuty && _dalamudUtilService.IsInDuty && !_dalamudUtilService.IsInFieldOperation)
{
return true;
}
return false;
}
protected override void DrawInternal()
{
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;
_chatEmoteService.EnsureGlobalEmotesLoaded();
PairUiSnapshot? pairSnapshot = null;
var itemSpacing = ImGui.GetStyle().ItemSpacing.X;
if (channel.Messages.Count == 0)
{
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3);
ImGui.TextWrapped("Chat history will appear here when available.");
ImGui.PopStyleColor();
}
else
{
var messageCount = channel.Messages.Count;
var contentMaxX = ImGui.GetWindowContentRegionMax().X;
var cursorStartX = ImGui.GetCursorPosX();
var lineHeightWithSpacing = ImGui.GetTextLineHeightWithSpacing();
var prefix = new float[messageCount + 1];
var totalHeight = 0f;
for (var i = 0; i < messageCount; i++)
{
var messageHeight = MeasureMessageHeight(channel, channel.Messages[i], showTimestamps, cursorStartX, contentMaxX, itemSpacing, ref pairSnapshot);
if (messageHeight <= 0f)
{
messageHeight = lineHeightWithSpacing;
}
totalHeight += messageHeight;
prefix[i + 1] = totalHeight;
}
var scrollY = ImGui.GetScrollY();
var windowHeight = ImGui.GetWindowHeight();
var startIndex = Math.Max(0, UpperBound(prefix, scrollY) - 1);
var endIndex = Math.Min(messageCount, LowerBound(prefix, scrollY + windowHeight));
startIndex = Math.Max(0, startIndex - 2);
endIndex = Math.Min(messageCount, endIndex + 2);
if (startIndex > 0)
{
ImGui.Dummy(new Vector2(1f, prefix[startIndex]));
}
for (var i = startIndex; i < endIndex; 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;
var showRoleIcons = false;
var isOwner = false;
var isModerator = false;
var isPinned = false;
if (channel.Type == ChatChannelType.Group
&& payload.Sender.Kind == ChatSenderKind.IdentifiedUser
&& payload.Sender.User is not null)
{
pairSnapshot ??= _pairUiService.GetSnapshot();
var groupId = channel.Descriptor.CustomKey;
if (!string.IsNullOrWhiteSpace(groupId)
&& pairSnapshot.GroupsByGid.TryGetValue(groupId, out var groupInfo))
{
var senderUid = payload.Sender.User.UID;
isOwner = string.Equals(senderUid, groupInfo.OwnerUID, StringComparison.Ordinal);
if (groupInfo.GroupPairUserInfos.TryGetValue(senderUid, out var info))
{
isModerator = info.IsModerator();
isPinned = info.IsPinned();
}
}
showRoleIcons = isOwner || isModerator || isPinned;
}
ImGui.BeginGroup();
ImGui.PushStyleColor(ImGuiCol.Text, color);
if (showRoleIcons)
{
if (!string.IsNullOrEmpty(timestampText))
{
ImGui.TextUnformatted(timestampText);
ImGui.SameLine(0f, 0f);
}
var hasIcon = false;
if (isModerator)
{
_uiSharedService.IconText(FontAwesomeIcon.UserShield, UIColors.Get("LightlessPurple"));
UiSharedService.AttachToolTip("Moderator");
hasIcon = true;
}
if (isOwner)
{
if (hasIcon)
{
ImGui.SameLine(0f, itemSpacing);
}
_uiSharedService.IconText(FontAwesomeIcon.Crown, UIColors.Get("LightlessYellow"));
UiSharedService.AttachToolTip("Owner");
hasIcon = true;
}
if (isPinned)
{
if (hasIcon)
{
ImGui.SameLine(0f, itemSpacing);
}
_uiSharedService.IconText(FontAwesomeIcon.Thumbtack, UIColors.Get("LightlessBlue"));
UiSharedService.AttachToolTip("Pinned");
hasIcon = true;
}
if (hasIcon)
{
ImGui.SameLine(0f, itemSpacing);
}
var messageStartX = ImGui.GetCursorPosX();
DrawChatMessageWithEmotes($"{message.DisplayName}: ", payload.Message, messageStartX);
}
else
{
var messageStartX = ImGui.GetCursorPosX();
DrawChatMessageWithEmotes($"{timestampText}{message.DisplayName}: ", payload.Message, messageStartX);
}
ImGui.PopStyleColor();
ImGui.EndGroup();
ImGui.SetNextWindowSizeConstraints(
new Vector2(190f * ImGuiHelpers.GlobalScale, 0f),
new Vector2(float.MaxValue, float.MaxValue));
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);
if (channel.Type == ChatChannelType.Group
&& payload.Sender.Kind == ChatSenderKind.IdentifiedUser
&& payload.Sender.User is not null)
{
var aliasOrUid = payload.Sender.User.AliasOrUID;
if (!string.IsNullOrWhiteSpace(aliasOrUid)
&& !string.Equals(message.DisplayName, aliasOrUid, StringComparison.Ordinal))
{
ImGui.TextDisabled(aliasOrUid);
}
}
ImGui.Separator();
var actionIndex = 0;
foreach (var action in GetContextMenuActions(channel, message))
{
DrawContextMenuAction(action, actionIndex++);
}
ImGui.EndPopup();
}
ImGui.PopID();
}
var remainingHeight = totalHeight - prefix[endIndex];
if (remainingHeight > 0f)
{
ImGui.Dummy(new Vector2(1f, remainingHeight));
}
}
if (_scrollToBottom)
{
ImGui.SetScrollHereY(1f);
_scrollToBottom = false;
}
if (restoreFontScale)
{
ImGui.SetWindowFontScale(1f);
}
}
private void DrawChatMessageWithEmotes(string prefix, string message, float lineStartX)
{
var segments = BuildChatSegments(prefix, message);
var firstOnLine = true;
var emoteSize = new Vector2(ImGui.GetTextLineHeight());
var remainingWidth = ImGui.GetContentRegionAvail().X;
foreach (var segment in segments)
{
if (segment.IsLineBreak)
{
if (firstOnLine)
{
ImGui.NewLine();
}
ImGui.SetCursorPosX(lineStartX);
firstOnLine = true;
remainingWidth = ImGui.GetContentRegionAvail().X;
continue;
}
if (segment.IsWhitespace && firstOnLine)
{
continue;
}
var segmentWidth = segment.IsEmote ? emoteSize.X : ImGui.CalcTextSize(segment.Text).X;
if (!firstOnLine)
{
if (segmentWidth > remainingWidth)
{
ImGui.SetCursorPosX(lineStartX);
firstOnLine = true;
remainingWidth = ImGui.GetContentRegionAvail().X;
if (segment.IsWhitespace)
{
continue;
}
}
else
{
ImGui.SameLine(0f, 0f);
}
}
if (segment.IsEmote && segment.Texture is not null)
{
ImGui.Image(segment.Texture.Handle, emoteSize);
if (ImGui.IsItemHovered())
{
DrawEmoteTooltip(segment.EmoteName ?? string.Empty, segment.Texture);
}
}
else
{
ImGui.TextUnformatted(segment.Text);
}
remainingWidth -= segmentWidth;
firstOnLine = false;
}
}
private void DrawEmotePickerPopup(ref string draft, string channelKey)
{
if (_openEmotePicker)
{
ImGui.OpenPopup(EmotePickerPopupId);
_openEmotePicker = false;
}
var style = ImGui.GetStyle();
var scale = ImGuiHelpers.GlobalScale;
var emoteSize = 32f * scale;
var itemWidth = emoteSize + (style.FramePadding.X * 2f);
var gridWidth = (itemWidth * EmotePickerColumns) + (style.ItemSpacing.X * Math.Max(0, EmotePickerColumns - 1));
var scrollbarPadding = style.ScrollbarSize + (style.ItemSpacing.X * 2f) + (8f * scale);
var windowWidth = gridWidth + scrollbarPadding + (style.WindowPadding.X * 2f);
ImGui.SetNextWindowSize(new Vector2(windowWidth, 340f * scale), ImGuiCond.Always);
if (!ImGui.BeginPopup(EmotePickerPopupId))
return;
ImGui.TextUnformatted("Emotes");
ImGui.Separator();
ImGui.SetNextItemWidth(-1f);
ImGui.InputTextWithHint("##emote_filter", "Search Emotes", ref _emoteFilter, 50);
ImGui.Spacing();
var emotes = _chatEmoteService.GetEmoteNames();
var filter = _emoteFilter.Trim();
var hasFilter = filter.Length > 0;
using (var child = ImRaii.Child("emote_picker_list", new Vector2(-1f, 0f), true))
{
if (child)
{
var any = false;
var itemHeight = emoteSize + (style.FramePadding.Y * 2f);
var cellWidth = itemWidth + style.ItemSpacing.X;
var availableWidth = Math.Max(1f, ImGui.GetContentRegionAvail().X);
var maxColumns = Math.Max(1, (int)MathF.Floor((availableWidth + style.ItemSpacing.X) / cellWidth));
var columns = Math.Max(1, Math.Min(EmotePickerColumns, maxColumns));
var columnIndex = 0;
foreach (var emote in emotes)
{
if (hasFilter && !emote.Contains(filter, StringComparison.OrdinalIgnoreCase))
{
continue;
}
any = true;
IDalamudTextureWrap? texture = null;
_chatEmoteService.TryGetEmote(emote, out texture);
ImGui.PushID(emote);
var clicked = false;
if (texture is not null)
{
var buttonSize = new Vector2(itemWidth, itemHeight);
clicked = ImGui.InvisibleButton("##emote_button", buttonSize);
var drawList = ImGui.GetWindowDrawList();
var itemMin = ImGui.GetItemRectMin();
var itemMax = ImGui.GetItemRectMax();
var bgColor = ImGui.IsItemActive()
? ImGui.GetColorU32(ImGuiCol.ButtonActive)
: ImGui.IsItemHovered()
? ImGui.GetColorU32(ImGuiCol.ButtonHovered)
: ImGui.GetColorU32(ImGuiCol.Button);
drawList.AddRectFilled(itemMin, itemMax, bgColor, style.FrameRounding);
var imageMin = itemMin + style.FramePadding;
var imageMax = imageMin + new Vector2(emoteSize);
drawList.AddImage(texture.Handle, imageMin, imageMax);
}
else
{
clicked = ImGui.Button("?", new Vector2(itemWidth, itemHeight));
}
if (ImGui.IsItemHovered())
{
DrawEmoteTooltip(emote, texture);
}
ImGui.PopID();
if (clicked)
{
AppendEmoteToDraft(ref draft, emote);
_draftMessages[channelKey] = draft;
_refocusChatInput = true;
_refocusChatInputKey = channelKey;
ImGui.CloseCurrentPopup();
break;
}
columnIndex++;
if (columnIndex < columns)
{
ImGui.SameLine();
}
else
{
columnIndex = 0;
}
}
if (!any)
{
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3);
ImGui.TextUnformatted(emotes.Count == 0 ? "Loading emotes..." : "No emotes found.");
ImGui.PopStyleColor();
}
}
}
ImGui.EndPopup();
}
private static void AppendEmoteToDraft(ref string draft, string emote)
{
if (string.IsNullOrWhiteSpace(draft))
{
draft = emote;
return;
}
if (char.IsWhiteSpace(draft[^1]))
{
draft += emote;
}
else
{
draft += " " + emote;
}
}
private List<ChatSegment> BuildChatSegments(string prefix, string message)
{
var segments = new List<ChatSegment>(Math.Max(16, message.Length / 4));
AppendChatSegments(segments, prefix, allowEmotes: false);
AppendChatSegments(segments, message, allowEmotes: true);
return segments;
}
private void AppendChatSegments(List<ChatSegment> segments, string text, bool allowEmotes)
{
if (string.IsNullOrEmpty(text))
{
return;
}
var index = 0;
while (index < text.Length)
{
if (text[index] == '\n')
{
segments.Add(ChatSegment.LineBreak());
index++;
continue;
}
if (text[index] == '\r')
{
index++;
continue;
}
if (char.IsWhiteSpace(text[index]))
{
var start = index;
while (index < text.Length && char.IsWhiteSpace(text[index]) && text[index] != '\n' && text[index] != '\r')
{
index++;
}
segments.Add(ChatSegment.FromText(text[start..index], isWhitespace: true));
continue;
}
var tokenStart = index;
while (index < text.Length && !char.IsWhiteSpace(text[index]))
{
index++;
}
var token = text[tokenStart..index];
if (allowEmotes && TrySplitToken(token, out var leading, out var core, out var trailing))
{
if (_chatEmoteService.TryGetEmote(core, out var texture) && texture is not null)
{
if (!string.IsNullOrEmpty(leading))
{
segments.Add(ChatSegment.FromText(leading));
}
segments.Add(ChatSegment.Emote(texture, core));
if (!string.IsNullOrEmpty(trailing))
{
segments.Add(ChatSegment.FromText(trailing));
}
continue;
}
}
segments.Add(ChatSegment.FromText(token));
}
}
private static bool TrySplitToken(string token, out string leading, out string core, out string trailing)
{
leading = string.Empty;
core = string.Empty;
trailing = string.Empty;
var start = 0;
while (start < token.Length && !IsEmoteChar(token[start]))
{
start++;
}
var end = token.Length - 1;
while (end >= start && !IsEmoteChar(token[end]))
{
end--;
}
if (start > end)
{
return false;
}
leading = token[..start];
core = token[start..(end + 1)];
trailing = token[(end + 1)..];
return true;
}
private static bool IsEmoteChar(char value)
{
return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '!' || value == '(' || value == ')';
}
private float MeasureMessageHeight(
ChatChannelSnapshot channel,
ChatMessageEntry message,
bool showTimestamps,
float cursorStartX,
float contentMaxX,
float itemSpacing,
ref PairUiSnapshot? pairSnapshot)
{
if (message.IsSystem)
{
return MeasureSystemEntryHeight(message);
}
if (message.Payload is not { } payload)
{
return 0f;
}
var timestampText = string.Empty;
if (showTimestamps)
{
timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] ";
}
var showRoleIcons = false;
var isOwner = false;
var isModerator = false;
var isPinned = false;
if (channel.Type == ChatChannelType.Group
&& payload.Sender.Kind == ChatSenderKind.IdentifiedUser
&& payload.Sender.User is not null)
{
pairSnapshot ??= _pairUiService.GetSnapshot();
var groupId = channel.Descriptor.CustomKey;
if (!string.IsNullOrWhiteSpace(groupId)
&& pairSnapshot.GroupsByGid.TryGetValue(groupId, out var groupInfo))
{
var senderUid = payload.Sender.User.UID;
isOwner = string.Equals(senderUid, groupInfo.OwnerUID, StringComparison.Ordinal);
if (groupInfo.GroupPairUserInfos.TryGetValue(senderUid, out var info))
{
isModerator = info.IsModerator();
isPinned = info.IsPinned();
}
}
showRoleIcons = isOwner || isModerator || isPinned;
}
var lineStartX = cursorStartX;
string prefix;
if (showRoleIcons)
{
lineStartX += MeasureRolePrefixWidth(timestampText, isOwner, isModerator, isPinned, itemSpacing);
prefix = $"{message.DisplayName}: ";
}
else
{
prefix = $"{timestampText}{message.DisplayName}: ";
}
var lines = MeasureChatMessageLines(prefix, payload.Message, lineStartX, contentMaxX);
return Math.Max(1, lines) * ImGui.GetTextLineHeightWithSpacing();
}
private int MeasureChatMessageLines(string prefix, string message, float lineStartX, float contentMaxX)
{
var segments = BuildChatSegments(prefix, message);
if (segments.Count == 0)
{
return 1;
}
var emoteWidth = ImGui.GetTextLineHeight();
var availableWidth = Math.Max(1f, contentMaxX - lineStartX);
var remainingWidth = availableWidth;
var firstOnLine = true;
var lines = 1;
foreach (var segment in segments)
{
if (segment.IsLineBreak)
{
lines++;
firstOnLine = true;
remainingWidth = availableWidth;
continue;
}
if (segment.IsWhitespace && firstOnLine)
{
continue;
}
var segmentWidth = segment.IsEmote ? emoteWidth : ImGui.CalcTextSize(segment.Text).X;
if (!firstOnLine)
{
if (segmentWidth > remainingWidth)
{
lines++;
firstOnLine = true;
remainingWidth = availableWidth;
if (segment.IsWhitespace)
{
continue;
}
}
}
remainingWidth -= segmentWidth;
firstOnLine = false;
}
return lines;
}
private float MeasureRolePrefixWidth(string timestampText, bool isOwner, bool isModerator, bool isPinned, float itemSpacing)
{
var width = 0f;
if (!string.IsNullOrEmpty(timestampText))
{
width += ImGui.CalcTextSize(timestampText).X;
}
var hasIcon = false;
if (isModerator)
{
width += MeasureIconWidth(FontAwesomeIcon.UserShield);
hasIcon = true;
}
if (isOwner)
{
if (hasIcon)
{
width += itemSpacing;
}
width += MeasureIconWidth(FontAwesomeIcon.Crown);
hasIcon = true;
}
if (isPinned)
{
if (hasIcon)
{
width += itemSpacing;
}
width += MeasureIconWidth(FontAwesomeIcon.Thumbtack);
hasIcon = true;
}
if (hasIcon)
{
width += itemSpacing;
}
return width;
}
private float MeasureIconWidth(FontAwesomeIcon icon)
{
using var font = _uiSharedService.IconFont.Push();
return ImGui.CalcTextSize(icon.ToIconString()).X;
}
private float MeasureSystemEntryHeight(ChatMessageEntry entry)
{
_ = entry;
var spacing = ImGui.GetStyle().ItemSpacing.Y;
var lineHeightWithSpacing = ImGui.GetTextLineHeightWithSpacing();
var separatorHeight = Math.Max(1f, ImGuiHelpers.GlobalScale);
var height = spacing;
height += lineHeightWithSpacing;
height += spacing * 0.35f;
height += separatorHeight;
height += spacing;
return height;
}
private static int LowerBound(float[] values, float target)
{
var low = 0;
var high = values.Length;
while (low < high)
{
var mid = (low + high) / 2;
if (values[mid] < target)
{
low = mid + 1;
}
else
{
high = mid;
}
}
return low;
}
private static int UpperBound(float[] values, float target)
{
var low = 0;
var high = values.Length;
while (low < high)
{
var mid = (low + high) / 2;
if (values[mid] <= target)
{
low = mid + 1;
}
else
{
high = mid;
}
}
return low;
}
private void DrawEmoteTooltip(string name, IDalamudTextureWrap? texture)
{
if (string.IsNullOrEmpty(name) && texture is null)
{
return;
}
ImGui.BeginTooltip();
ImGui.SetWindowFontScale(1f);
if (texture is not null)
{
var size = 48f * ImGuiHelpers.GlobalScale;
ImGui.Image(texture.Handle, new Vector2(size));
}
if (!string.IsNullOrEmpty(name))
{
if (texture is not null)
{
ImGui.Spacing();
}
ImGui.TextUnformatted(name);
}
ImGui.EndTooltip();
}
private readonly record struct ChatSegment(string Text, IDalamudTextureWrap? Texture, string? EmoteName, bool IsEmote, bool IsWhitespace, bool IsLineBreak)
{
public static ChatSegment FromText(string text, bool isWhitespace = false) => new(text, null, null, false, isWhitespace, false);
public static ChatSegment Emote(IDalamudTextureWrap texture, string name) => new(string.Empty, texture, name, true, false, false);
public static ChatSegment LineBreak() => new(string.Empty, null, null, false, false, true);
}
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 = 70f * ImGuiHelpers.GlobalScale;
var emoteButtonWidth = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Comments).X;
var counterWidth = ImGui.CalcTextSize($"{MaxMessageLength}/{MaxMessageLength}").X;
var reservedWidth = sendButtonWidth + emoteButtonWidth + counterWidth + style.ItemSpacing.X * 3f;
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())
{
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 minButtonX = buttonScreenPos.X + style.ItemSpacing.X;
var desiredSendX = rightEdgeScreen - sendButtonWidth;
var sendX = MathF.Max(minButtonX + emoteButtonWidth + style.ItemSpacing.X, desiredSendX);
var emoteX = sendX - style.ItemSpacing.X - emoteButtonWidth;
ImGui.SetCursorScreenPos(new Vector2(emoteX, buttonScreenPos.Y));
if (_uiSharedService.IconButton(FontAwesomeIcon.Comments))
{
_openEmotePicker = true;
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Open Emotes");
}
ImGui.SetCursorScreenPos(new Vector2(sendX, 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}", sendButtonWidth, center: true))
{
sendClicked = true;
}
}
ImGui.PopStyleVar();
ImGui.PopStyleColor(3);
DrawEmotePickerPopup(ref draft, channel.Key);
if (canSend && (enterPressed || sendClicked))
{
_refocusChatInput = true;
_refocusChatInputKey = channel.Key;
var draftAtSend = draft;
var sanitized = SanitizeOutgoingDraft(draftAtSend);
if (sanitized is not null)
{
TrackPendingDraftClear(channel.Key, sanitized);
draft = string.Empty;
_draftMessages[channel.Key] = draft;
_scrollToBottom = true;
_ = Task.Run(async () =>
{
try
{
var succeeded = await _zoneChatService.SendMessageAsync(channel.Descriptor, sanitized).ConfigureAwait(false);
if (!succeeded)
{
RemovePendingDraftClear(channel.Key, sanitized);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send chat message");
RemovePendingDraftClear(channel.Key, sanitized);
}
});
}
}
}
private void DrawRulesOverlay()
{
var parentContentMin = ImGui.GetWindowContentRegionMin();
var parentContentMax = ImGui.GetWindowContentRegionMax();
var overlaySize = parentContentMax - parentContentMin;
if (overlaySize.X <= 0f || overlaySize.Y <= 0f)
{
parentContentMin = Vector2.Zero;
overlaySize = ImGui.GetWindowSize();
}
var previousCursor = ImGui.GetCursorPos();
ImGui.SetCursorPos(parentContentMin);
var bgColor = ImGui.GetStyle().Colors[(int)ImGuiCol.WindowBg];
bgColor.W = 0.86f;
ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 6f * ImGuiHelpers.GlobalScale);
ImGui.PushStyleVar(ImGuiStyleVar.ChildBorderSize, 0f);
ImGui.PushStyleColor(ImGuiCol.Border, Vector4.Zero);
ImGui.PushStyleColor(ImGuiCol.ChildBg, bgColor);
var overlayFlags = ImGuiWindowFlags.NoScrollbar
| ImGuiWindowFlags.NoScrollWithMouse
| ImGuiWindowFlags.NoSavedSettings;
if (ImGui.BeginChild("##zone_chat_rules_overlay", overlaySize, false, 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;
}
}
ImGui.EndChild();
ImGui.PopStyleColor(2);
ImGui.PopStyleVar(2);
ImGui.SetCursorPos(previousCursor);
}
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();
}
public override void PostDraw()
{
if (_pushedStyle)
{
ImGui.PopStyleVar(1);
_pushedStyle = false;
}
if (_titleBarStylePopCount > 0)
{
ImGui.PopStyleColor(_titleBarStylePopCount);
_titleBarStylePopCount = 0;
}
base.PostDraw();
}
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 sanitizedMessage)
{
if (string.IsNullOrWhiteSpace(sanitizedMessage))
return false;
bool succeeded;
try
{
succeeded = _zoneChatService.SendMessageAsync(channel.Descriptor, sanitizedMessage).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;
}
var moderationActions = new List<ChatMessageContextAction>();
foreach (var action in GetSyncshellModerationActions(channel, message, payload))
{
moderationActions.Add(action);
}
if (moderationActions.Count > 0)
{
yield return ChatMessageContextAction.Separator();
foreach (var action in moderationActions)
{
yield return action;
}
}
}
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 IEnumerable<ChatMessageContextAction> GetSyncshellModerationActions(ChatChannelSnapshot channel, ChatMessageEntry message, ChatMessageDto payload)
{
if (channel.Type != ChatChannelType.Group)
yield break;
if (message.FromSelf)
yield break;
if (payload.Sender.Kind != ChatSenderKind.IdentifiedUser || payload.Sender.User is null)
yield break;
var groupId = channel.Descriptor.CustomKey;
if (string.IsNullOrWhiteSpace(groupId))
yield break;
var snapshot = _pairUiService.GetSnapshot();
if (!snapshot.GroupsByGid.TryGetValue(groupId, out var groupInfo))
yield break;
var sender = payload.Sender.User;
var senderUid = sender.UID;
if (string.IsNullOrWhiteSpace(senderUid))
yield break;
var selfIsOwner = string.Equals(groupInfo.OwnerUID, _apiController.UID, StringComparison.Ordinal);
var selfIsModerator = groupInfo.GroupUserInfo.IsModerator();
if (!selfIsOwner && !selfIsModerator)
yield break;
var senderInfo = groupInfo.GroupPairUserInfos.TryGetValue(senderUid, out var info) ? info : GroupPairUserInfo.None;
var userIsModerator = senderInfo.IsModerator();
var userIsPinned = senderInfo.IsPinned();
var showModeratorActions = selfIsOwner || (selfIsModerator && !userIsModerator);
if (!showModeratorActions)
yield break;
if (showModeratorActions)
{
var pinLabel = userIsPinned ? "Unpin user" : "Pin user";
yield return new ChatMessageContextAction(
FontAwesomeIcon.Thumbtack,
pinLabel,
true,
() =>
{
var updatedInfo = senderInfo;
updatedInfo.SetPinned(!userIsPinned);
_ = _apiController.GroupSetUserInfo(new GroupPairUserInfoDto(groupInfo.Group, sender, updatedInfo));
});
var removeEnabled = UiSharedService.CtrlPressed();
var removeLabel = removeEnabled ? "Remove user" : "Remove user (Hold CTRL)";
yield return new ChatMessageContextAction(
FontAwesomeIcon.Trash,
removeLabel,
removeEnabled,
() => _ = _apiController.GroupRemoveUser(new GroupPairDto(groupInfo.Group, sender)),
"Syncshell action: removes the user from the syncshell, not just chat.");
var banPair = ResolveBanPair(snapshot, senderUid, sender, groupInfo);
var banEnabled = UiSharedService.CtrlPressed();
var banLabel = banEnabled ? "Ban user" : "Ban user (Hold CTRL)";
yield return new ChatMessageContextAction(
FontAwesomeIcon.UserSlash,
banLabel,
banEnabled,
() => Mediator.Publish(new OpenBanUserPopupMessage(banPair!, groupInfo)),
"Hold CTRL to ban the user from the syncshell, not just chat.");
}
}
private Pair? ResolveBanPair(PairUiSnapshot snapshot, string senderUid, UserData sender, GroupFullInfoDto groupInfo)
{
if (snapshot.PairsByUid.TryGetValue(senderUid, out var pair))
{
return pair;
}
var connection = new PairConnection(sender);
var entry = new PairDisplayEntry(new PairUniqueIdentifier(senderUid), connection, new[] { groupInfo }, null);
return _pairFactory.Create(entry);
}
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;
}
if (!message.Message.FromSelf || message.Message.Payload?.Message is not { Length: > 0 } payloadText)
{
return;
}
var matchedPending = false;
if (_pendingDraftClears.TryGetValue(message.ChannelKey, out var pending))
{
var pendingIndex = pending.FindIndex(text => string.Equals(text, payloadText, StringComparison.Ordinal));
if (pendingIndex >= 0)
{
pending.RemoveAt(pendingIndex);
matchedPending = true;
if (pending.Count == 0)
{
_pendingDraftClears.Remove(message.ChannelKey);
}
}
}
if (matchedPending && _draftMessages.TryGetValue(message.ChannelKey, out var currentDraft))
{
var sanitizedCurrent = SanitizeOutgoingDraft(currentDraft);
if (sanitizedCurrent is not null && string.Equals(sanitizedCurrent, payloadText, StringComparison.Ordinal))
{
_draftMessages[message.ChannelKey] = string.Empty;
}
}
}
private static string? SanitizeOutgoingDraft(string draft)
{
if (string.IsNullOrWhiteSpace(draft))
{
return null;
}
var sanitized = draft.Trim().ReplaceLineEndings(" ");
if (sanitized.Length == 0)
{
return null;
}
if (sanitized.Length > ZoneChatService.MaxOutgoingLength)
{
sanitized = sanitized[..ZoneChatService.MaxOutgoingLength];
}
return sanitized;
}
private void TrackPendingDraftClear(string channelKey, string message)
{
if (!_pendingDraftClears.TryGetValue(channelKey, out var pending))
{
pending = new List<string>();
_pendingDraftClears[channelKey] = pending;
}
pending.Add(message);
const int MaxPendingDrafts = 12;
if (pending.Count > MaxPendingDrafts)
{
pending.RemoveAt(0);
}
}
private void RemovePendingDraftClear(string channelKey, string message)
{
if (!_pendingDraftClears.TryGetValue(channelKey, out var pending))
{
return;
}
var index = pending.FindIndex(text => string.Equals(text, message, StringComparison.Ordinal));
if (index < 0)
{
return;
}
pending.RemoveAt(index);
if (pending.Count == 0)
{
_pendingDraftClears.Remove(channelKey);
}
}
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.");
}
var showNotesInSyncshellChat = chatConfig.ShowNotesInSyncshellChat;
if (ImGui.Checkbox("Show notes in syncshell chat", ref showNotesInSyncshellChat))
{
chatConfig.ShowNotesInSyncshellChat = showNotesInSyncshellChat;
_chatConfigService.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("When enabled, your notes replace user names in syncshell chat.");
}
var enableAnimatedEmotes = chatConfig.EnableAnimatedEmotes;
if (ImGui.Checkbox("Enable animated emotes", ref enableAnimatedEmotes))
{
chatConfig.EnableAnimatedEmotes = enableAnimatedEmotes;
_chatConfigService.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("When disabled, emotes render as static images.");
}
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);
if (action.IsSeparator)
{
ImGui.Separator();
ImGui.PopID();
return;
}
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 (action.Tooltip is { Length: > 0 } && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
{
ImGui.SetTooltip(action.Tooltip);
}
if (clicked && action.IsEnabled)
{
ImGui.CloseCurrentPopup();
action.Execute();
}
ImGui.PopID();
}
private static void NoopContextAction()
{
}
private readonly record struct ChatMessageContextAction(FontAwesomeIcon? Icon, string Label, bool IsEnabled, Action Execute, string? Tooltip = null, bool IsSeparator = false)
{
public static ChatMessageContextAction Separator() => new(null, string.Empty, false, ZoneChatUi.NoopContextAction, null, true);
}
}