Files
LightlessClient/LightlessSync/UI/ZoneChatUi.cs
2026-01-16 11:00:58 +09:00

4211 lines
156 KiB
C#

using System.Globalization;
using System.Numerics;
using System.Reflection;
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 Dalamud.Interface.Windowing;
using LightlessSync.API.Dto.Chat;
using LightlessSync.API.Dto.Group;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Configurations;
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 string MentionPopupId = "zone_chat_mention_popup";
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 MinEmoteScale = 0.5f;
private const float MaxEmoteScale = 2.0f;
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 const int MaxBadgeDisplay = 99;
private const int MaxMentionSuggestions = 8;
private const int CollapsedMessageCountDisplayCap = 999;
private static readonly FieldInfo? FadeOutOriginField = typeof(Window).GetField("fadeOutOrigin", BindingFlags.Instance | BindingFlags.NonPublic);
private static readonly FieldInfo? FadeOutSizeField = typeof(Window).GetField("fadeOutSize", BindingFlags.Instance | BindingFlags.NonPublic);
private enum ChatSettingsTab
{
General,
Messages,
Notifications,
Visibility,
Window
}
private static readonly UiSharedService.TabOption<ChatSettingsTab>[] ChatSettingsTabOptions =
[
new UiSharedService.TabOption<ChatSettingsTab>("General", ChatSettingsTab.General),
new UiSharedService.TabOption<ChatSettingsTab>("Messages", ChatSettingsTab.Messages),
new UiSharedService.TabOption<ChatSettingsTab>("Notifications", ChatSettingsTab.Notifications),
new UiSharedService.TabOption<ChatSettingsTab>("Visibility", ChatSettingsTab.Visibility),
new UiSharedService.TabOption<ChatSettingsTab>("Window", ChatSettingsTab.Window),
];
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 string? _activeInputChannelKey;
private int _pendingDraftCursorPos = -1;
private string? _pendingDraftCursorChannelKey;
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 int _mentionSelectionIndex = -1;
private string? _mentionSelectionKey;
private bool _HideStateActive;
private bool _HideStateWasOpen;
private bool _pushedStyle;
private ChatSettingsTab _selectedChatSettingsTab = ChatSettingsTab.General;
private bool _isWindowCollapsed;
private bool _wasWindowCollapsed;
private int _collapsedMessageCount;
private bool _forceExpandOnOpen;
private Vector2 _lastWindowPos;
private Vector2 _lastWindowSize;
private bool _hasWindowMetrics;
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, 1f);
_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);
_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();
_lastWindowPos = windowPos;
_lastWindowSize = windowSize;
_hasWindowMetrics = true;
UpdateCollapsedState(isCollapsed: false);
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();
IReadOnlyList<ChatChannelSnapshot> channels = _zoneChatService.GetChannelsSnapshot();
IReadOnlyList<ChatChannelSnapshot> visibleChannels = GetVisibleChannels(channels);
DrawReportPopup();
CleanupDrafts(channels);
if (channels.Count == 0)
{
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3);
ImGui.TextWrapped("No chat channels available.");
ImGui.PopStyleColor();
}
else if (visibleChannels.Count == 0)
{
EnsureSelectedChannel(visibleChannels);
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3);
ImGui.TextWrapped("All chat channels are hidden. Open chat settings to show channels.");
ImGui.PopStyleColor();
}
else
{
EnsureSelectedChannel(visibleChannels);
DrawChannelButtons(visibleChannels);
if (_selectedChannelKey is null)
{
selune.DrawHighlightOnly(ImGui.GetIO().DeltaTime);
return;
}
ChatChannelSnapshot activeChannel = visibleChannels.FirstOrDefault(channel => string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal));
if (activeChannel.Equals(default(ChatChannelSnapshot)))
{
activeChannel = visibleChannels[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 DrawCollapsedMessageBadge(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize)
{
if (_collapsedMessageCount <= 0)
{
return;
}
var style = ImGui.GetStyle();
var titleBarHeight = ImGui.GetFontSize() + style.FramePadding.Y * 2f;
var scale = ImGuiHelpers.GlobalScale;
var displayCount = _collapsedMessageCount > CollapsedMessageCountDisplayCap
? $"{CollapsedMessageCountDisplayCap}+"
: _collapsedMessageCount.ToString(CultureInfo.InvariantCulture);
var padding = new Vector2(8f, 3f) * scale;
var title = WindowName ?? string.Empty;
var titleSplitIndex = title.IndexOf("###", StringComparison.Ordinal);
if (titleSplitIndex >= 0)
{
title = title[..titleSplitIndex];
}
var titleSize = ImGui.CalcTextSize(title);
var leftEdge = windowPos.X + style.FramePadding.X + titleSize.X + style.ItemInnerSpacing.X + 6f * scale;
var buttonCount = GetTitleBarButtonCount();
var buttonWidth = ImGui.GetFrameHeight();
var buttonSpacing = style.ItemInnerSpacing.X;
var buttonArea = buttonCount > 0
? (buttonWidth * buttonCount) + (buttonSpacing * (buttonCount - 1))
: 0f;
var rightEdge = windowPos.X + windowSize.X - style.FramePadding.X - buttonArea;
var availableWidth = rightEdge - leftEdge;
if (availableWidth <= 0f)
{
return;
}
string label = $"New messages: {displayCount}";
var textSize = ImGui.CalcTextSize(label);
var badgeSize = textSize + padding * 2f;
if (badgeSize.X > availableWidth)
{
label = $"New: {displayCount}";
textSize = ImGui.CalcTextSize(label);
badgeSize = textSize + padding * 2f;
}
if (badgeSize.X > availableWidth)
{
label = displayCount;
textSize = ImGui.CalcTextSize(label);
badgeSize = textSize + padding * 2f;
}
if (badgeSize.X > availableWidth)
{
return;
}
var posX = MathF.Max(leftEdge, rightEdge - badgeSize.X);
var posY = windowPos.Y + (titleBarHeight - badgeSize.Y) * 0.5f;
var badgeMin = new Vector2(posX, posY);
var badgeMax = badgeMin + badgeSize;
var time = (float)ImGui.GetTime();
var pulse = 0.6f + 0.2f * (1f + MathF.Sin(time * 2f));
var baseColor = UIColors.Get("DimRed");
var fillColor = new Vector4(baseColor.X, baseColor.Y, baseColor.Z, baseColor.W * pulse);
drawList.AddRectFilled(badgeMin, badgeMax, ImGui.ColorConvertFloat4ToU32(fillColor), 6f * scale);
drawList.AddText(badgeMin + padding, ImGui.ColorConvertFloat4ToU32(ImGuiColors.DalamudWhite), label);
}
private int GetTitleBarButtonCount()
{
var count = 0;
if (!Flags.HasFlag(ImGuiWindowFlags.NoCollapse))
{
count++;
}
if (ShowCloseButton)
{
count++;
}
if (AllowPinning || AllowClickthrough)
{
count++;
}
count += TitleBarButtons?.Count ?? 0;
return count;
}
private void UpdateCollapsedState(bool isCollapsed)
{
if (isCollapsed != _wasWindowCollapsed)
{
_collapsedMessageCount = 0;
_wasWindowCollapsed = isCollapsed;
}
_isWindowCollapsed = isCollapsed;
}
private bool TryUpdateWindowMetricsFromBase()
{
if (FadeOutOriginField is null || FadeOutSizeField is null)
{
return false;
}
if (FadeOutOriginField.GetValue(this) is Vector2 pos && FadeOutSizeField.GetValue(this) is Vector2 size)
{
_lastWindowPos = pos;
_lastWindowSize = size;
_hasWindowMetrics = true;
return true;
}
return false;
}
private static bool IsLikelyCollapsed(Vector2 windowSize)
{
var style = ImGui.GetStyle();
var titleHeight = ImGui.GetFontSize() + style.FramePadding.Y * 2f;
var threshold = titleHeight + style.WindowBorderSize * 2f + 2f * ImGuiHelpers.GlobalScale;
return windowSize.Y <= threshold;
}
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;
MentionHighlightData? mentionHighlightData = 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
{
if (channel.Type == ChatChannelType.Group)
{
pairSnapshot ??= _pairUiService.GetSnapshot();
mentionHighlightData = BuildMentionHighlightData(channel, pairSnapshot);
}
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, mentionHighlightData, 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);
var mentionContextOpen = false;
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();
mentionContextOpen = DrawChatMessageWithEmotes($"{message.DisplayName}: ", payload.Message, messageStartX, mentionHighlightData);
}
else
{
var messageStartX = ImGui.GetCursorPosX();
mentionContextOpen = DrawChatMessageWithEmotes($"{timestampText}{message.DisplayName}: ", payload.Message, messageStartX, mentionHighlightData);
}
ImGui.PopStyleColor();
ImGui.EndGroup();
ImGui.SetNextWindowSizeConstraints(
new Vector2(190f * ImGuiHelpers.GlobalScale, 0f),
new Vector2(float.MaxValue, float.MaxValue));
var messagePopupFlags = ImGuiPopupFlags.MouseButtonRight | ImGuiPopupFlags.NoOpenOverExistingPopup;
if (!mentionContextOpen && ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}", messagePopupFlags))
{
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 bool DrawChatMessageWithEmotes(string prefix, string message, float lineStartX, MentionHighlightData? mentionHighlightData)
{
var segments = BuildChatSegments(prefix, message, mentionHighlightData);
var firstOnLine = true;
var emoteSizeValue = ImGui.GetTextLineHeight() * GetEmoteScale();
var emoteSize = new Vector2(emoteSizeValue);
var remainingWidth = ImGui.GetContentRegionAvail().X;
var mentionIndex = 0;
var mentionContextOpen = false;
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
{
if (segment.IsMention)
{
Vector4 mentionColor = segment.IsSelfMention
? UIColors.Get("LightlessYellow")
: UIColors.Get("LightlessPurple");
ImGui.PushStyleColor(ImGuiCol.Text, mentionColor);
ImGui.TextUnformatted(segment.Text);
ImGui.PopStyleColor();
mentionContextOpen |= DrawMentionContextMenu(segment.Text, mentionHighlightData, mentionIndex++);
}
else
{
ImGui.TextUnformatted(segment.Text);
}
}
remainingWidth -= segmentWidth;
firstOnLine = false;
}
return mentionContextOpen;
}
private bool DrawMentionContextMenu(string mentionText, MentionHighlightData? mentionHighlightData, int mentionIndex)
{
string token = mentionText;
if (!string.IsNullOrEmpty(token) && token[0] == '@')
{
token = token[1..];
}
MentionUserInfo? mentionInfo = null;
if (mentionHighlightData.HasValue
&& !string.IsNullOrWhiteSpace(token)
&& mentionHighlightData.Value.Users.TryGetValue(token, out var userInfo))
{
mentionInfo = userInfo;
}
string statusLabel = "Unknown";
bool canViewProfile = false;
Action? viewProfileAction = null;
if (mentionInfo.HasValue)
{
var info = mentionInfo.Value;
if (info.IsSelf)
{
statusLabel = "You";
}
else if (info.Pair is not null)
{
statusLabel = info.Pair.IsOnline ? "Online" : "Offline";
}
if (info.Pair is not null)
{
canViewProfile = true;
viewProfileAction = () => Mediator.Publish(new ProfileOpenStandaloneMessage(info.Pair));
}
else if (info.UserData is not null)
{
canViewProfile = true;
var userData = info.UserData;
viewProfileAction = () => RunContextAction(() => OpenStandardProfileAsync(userData));
}
}
var style = ImGui.GetStyle();
var iconWidth = _uiSharedService.GetIconSize(FontAwesomeIcon.User).X;
var actionWidth = ImGui.CalcTextSize("View Profile").X + iconWidth + style.ItemSpacing.X;
var baseWidth = MathF.Max(
MathF.Max(ImGui.CalcTextSize(mentionText).X, ImGui.CalcTextSize(statusLabel).X),
actionWidth);
var targetWidth = (baseWidth + style.WindowPadding.X * 2f + style.FramePadding.X * 2f) * 1.5f;
ImGui.SetNextWindowSizeConstraints(new Vector2(targetWidth, 0f), new Vector2(float.MaxValue, float.MaxValue));
if (!ImGui.BeginPopupContextItem($"mention_ctx##{mentionIndex}"))
{
return false;
}
ImGui.TextUnformatted(mentionText);
ImGui.Separator();
ImGui.TextDisabled(statusLabel);
ImGui.Separator();
var profileAction = new ChatMessageContextAction(
FontAwesomeIcon.User,
"View Profile",
canViewProfile,
viewProfileAction ?? NoopContextAction);
DrawContextMenuAction(profileAction, 0);
ImGui.EndPopup();
return true;
}
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, MentionHighlightData? mentionHighlightData)
{
var segments = new List<ChatSegment>(Math.Max(16, message.Length / 4));
AppendChatSegments(segments, prefix, allowEmotes: false, mentionHighlightData: null);
AppendChatSegments(segments, message, allowEmotes: true, mentionHighlightData);
return segments;
}
private void AppendChatSegments(List<ChatSegment> segments, string text, bool allowEmotes, MentionHighlightData? mentionHighlightData)
{
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 (mentionHighlightData.HasValue
&& TrySplitMentionToken(token, mentionHighlightData.Value, out var leadingMention, out var mentionText, out var trailingMention, out var isSelfMention))
{
if (!string.IsNullOrEmpty(leadingMention))
{
segments.Add(ChatSegment.FromText(leadingMention));
}
segments.Add(ChatSegment.Mention(mentionText, isSelfMention));
if (!string.IsNullOrEmpty(trailingMention))
{
segments.Add(ChatSegment.FromText(trailingMention));
}
continue;
}
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 static bool TrySplitMentionToken(string token, MentionHighlightData mentionHighlightData, out string leading, out string mentionText, out string trailing, out bool isSelfMention)
{
leading = string.Empty;
mentionText = string.Empty;
trailing = string.Empty;
isSelfMention = false;
if (string.IsNullOrEmpty(token) || mentionHighlightData.Tokens.Count == 0)
{
return false;
}
for (int index = 0; index < token.Length; index++)
{
if (token[index] != '@')
{
continue;
}
if (index > 0 && IsMentionChar(token[index - 1]))
{
continue;
}
int start = index + 1;
int end = start;
while (end < token.Length && IsMentionChar(token[end]))
{
end++;
}
if (end == start)
{
continue;
}
string mentionToken = token[start..end];
if (!mentionHighlightData.Tokens.TryGetValue(mentionToken, out bool matchedSelf))
{
continue;
}
leading = token[..index];
mentionText = "@" + mentionToken;
trailing = token[end..];
isSelfMention = matchedSelf;
return true;
}
return false;
}
private static bool IsMentionChar(char value)
{
return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '\'';
}
private static bool IsMentionToken(ReadOnlySpan<char> token, bool allowEmpty)
{
if (token.Length == 0)
{
return allowEmpty;
}
for (int i = 0; i < token.Length; i++)
{
if (!IsMentionChar(token[i]))
{
return false;
}
}
return true;
}
private static bool TryGetMentionQuery(string text, out MentionQuery mentionQuery)
{
mentionQuery = default;
if (string.IsNullOrEmpty(text))
{
return false;
}
int cursor = text.Length;
int index = cursor - 1;
while (index >= 0)
{
char current = text[index];
if (current == '@')
{
if (index > 0 && IsMentionChar(text[index - 1]))
{
return false;
}
ReadOnlySpan<char> tokenSpan = text.AsSpan(index + 1, cursor - (index + 1));
if (!IsMentionToken(tokenSpan, allowEmpty: true))
{
return false;
}
mentionQuery = new MentionQuery(index, cursor, tokenSpan.ToString());
return true;
}
if (char.IsWhiteSpace(current))
{
return false;
}
if (!IsMentionChar(current))
{
return false;
}
index--;
}
return false;
}
private static string? GetPreferredMentionToken(string uid, string? alias)
{
if (!string.IsNullOrWhiteSpace(alias) && IsMentionToken(alias.AsSpan(), allowEmpty: false))
{
return alias;
}
if (IsMentionToken(uid.AsSpan(), allowEmpty: false))
{
return uid;
}
return null;
}
private static void AddMentionToken(Dictionary<string, bool> tokens, string token, bool isSelf)
{
if (tokens.TryGetValue(token, out bool existing))
{
if (isSelf && !existing)
{
tokens[token] = true;
}
return;
}
tokens[token] = isSelf;
}
private static void AddMentionUserToken(
Dictionary<string, MentionUserInfo> users,
HashSet<string> ambiguousTokens,
string token,
MentionUserInfo info)
{
if (ambiguousTokens.Contains(token))
{
return;
}
if (users.TryGetValue(token, out var existing))
{
if (!string.Equals(existing.Uid, info.Uid, StringComparison.Ordinal))
{
users.Remove(token);
ambiguousTokens.Add(token);
}
return;
}
users[token] = info;
}
private static void AddMentionData(
Dictionary<string, bool> tokens,
Dictionary<string, MentionUserInfo> users,
HashSet<string> ambiguousTokens,
string uid,
string? alias,
bool isSelf,
Pair? pair,
UserData? userData)
{
if (string.IsNullOrWhiteSpace(uid))
{
return;
}
var info = new MentionUserInfo(uid, userData, pair, isSelf);
if (IsMentionToken(uid.AsSpan(), allowEmpty: false))
{
AddMentionToken(tokens, uid, isSelf);
AddMentionUserToken(users, ambiguousTokens, uid, info);
}
if (!string.IsNullOrWhiteSpace(alias) && IsMentionToken(alias.AsSpan(), allowEmpty: false))
{
AddMentionToken(tokens, alias, isSelf);
AddMentionUserToken(users, ambiguousTokens, alias, info);
}
}
private static IReadOnlyList<Pair> GetPairsForGroup(PairUiSnapshot snapshot, string groupId, GroupFullInfoDto? groupInfo)
{
if (groupInfo is not null && snapshot.GroupPairs.TryGetValue(groupInfo, out IReadOnlyList<Pair> groupPairs))
{
return groupPairs;
}
foreach (KeyValuePair<GroupFullInfoDto, IReadOnlyList<Pair>> entry in snapshot.GroupPairs)
{
if (string.Equals(entry.Key.Group.GID, groupId, StringComparison.Ordinal))
{
return entry.Value;
}
}
return Array.Empty<Pair>();
}
private void AddMentionCandidate(List<MentionCandidate> candidates, HashSet<string> seenTokens, string uid, string? alias, string? note, bool isSelf, bool includeSelf)
{
if (!includeSelf && isSelf)
{
return;
}
string? token = GetPreferredMentionToken(uid, alias);
if (string.IsNullOrWhiteSpace(token))
{
return;
}
if (!seenTokens.Add(token))
{
return;
}
string displayName = !string.IsNullOrWhiteSpace(alias) ? alias : uid;
candidates.Add(new MentionCandidate(token, displayName, note, uid, isSelf));
}
private List<MentionCandidate> BuildMentionCandidates(ChatChannelSnapshot channel, PairUiSnapshot snapshot, bool includeSelf)
{
List<MentionCandidate> candidates = new();
if (channel.Type != ChatChannelType.Group)
{
return candidates;
}
string? groupId = channel.Descriptor.CustomKey;
if (string.IsNullOrWhiteSpace(groupId))
{
return candidates;
}
HashSet<string> seenTokens = new(StringComparer.OrdinalIgnoreCase);
string selfUid = _apiController.UID;
GroupFullInfoDto? groupInfo = null;
if (snapshot.GroupsByGid.TryGetValue(groupId, out GroupFullInfoDto found))
{
groupInfo = found;
}
if (groupInfo is not null)
{
bool ownerIsSelf = string.Equals(groupInfo.Owner.UID, selfUid, StringComparison.Ordinal);
string? ownerNote = _serverConfigurationManager.GetNoteForUid(groupInfo.Owner.UID);
AddMentionCandidate(candidates, seenTokens, groupInfo.Owner.UID, groupInfo.Owner.Alias, ownerNote, ownerIsSelf, includeSelf);
IReadOnlyList<Pair> groupPairs = GetPairsForGroup(snapshot, groupId, groupInfo);
foreach (Pair pair in groupPairs)
{
bool isSelf = string.Equals(pair.UserData.UID, selfUid, StringComparison.Ordinal);
string? note = pair.GetNote();
AddMentionCandidate(candidates, seenTokens, pair.UserData.UID, pair.UserData.Alias, note, isSelf, includeSelf);
}
}
else
{
IReadOnlyList<Pair> groupPairs = GetPairsForGroup(snapshot, groupId, null);
foreach (Pair pair in groupPairs)
{
bool isSelf = string.Equals(pair.UserData.UID, selfUid, StringComparison.Ordinal);
string? note = pair.GetNote();
AddMentionCandidate(candidates, seenTokens, pair.UserData.UID, pair.UserData.Alias, note, isSelf, includeSelf);
}
}
if (includeSelf)
{
string? note = _serverConfigurationManager.GetNoteForUid(selfUid);
AddMentionCandidate(candidates, seenTokens, selfUid, _apiController.DisplayName, note, isSelf: true, includeSelf: true);
}
return candidates;
}
private MentionHighlightData? BuildMentionHighlightData(ChatChannelSnapshot channel, PairUiSnapshot snapshot)
{
if (channel.Type != ChatChannelType.Group)
{
return null;
}
string? groupId = channel.Descriptor.CustomKey;
if (string.IsNullOrWhiteSpace(groupId))
{
return null;
}
Dictionary<string, bool> tokens = new(StringComparer.OrdinalIgnoreCase);
Dictionary<string, MentionUserInfo> users = new(StringComparer.OrdinalIgnoreCase);
HashSet<string> ambiguousTokens = new(StringComparer.OrdinalIgnoreCase);
string selfUid = _apiController.UID;
if (!string.IsNullOrWhiteSpace(selfUid))
{
var selfData = new UserData(selfUid, _apiController.DisplayName);
snapshot.PairsByUid.TryGetValue(selfUid, out var selfPair);
AddMentionData(tokens, users, ambiguousTokens, selfUid, _apiController.DisplayName, true, selfPair, selfData);
}
GroupFullInfoDto? groupInfo = null;
if (snapshot.GroupsByGid.TryGetValue(groupId, out GroupFullInfoDto found))
{
groupInfo = found;
}
if (groupInfo is not null)
{
bool ownerIsSelf = string.Equals(groupInfo.Owner.UID, selfUid, StringComparison.Ordinal);
var ownerUid = groupInfo.Owner.UID;
snapshot.PairsByUid.TryGetValue(ownerUid, out var ownerPair);
AddMentionData(tokens, users, ambiguousTokens, ownerUid, groupInfo.Owner.Alias, ownerIsSelf, ownerPair, groupInfo.Owner);
IReadOnlyList<Pair> groupPairs = GetPairsForGroup(snapshot, groupId, groupInfo);
foreach (Pair pair in groupPairs)
{
bool isSelf = string.Equals(pair.UserData.UID, selfUid, StringComparison.Ordinal);
AddMentionData(tokens, users, ambiguousTokens, pair.UserData.UID, pair.UserData.Alias, isSelf, pair, pair.UserData);
}
}
else
{
IReadOnlyList<Pair> groupPairs = GetPairsForGroup(snapshot, groupId, null);
foreach (Pair pair in groupPairs)
{
bool isSelf = string.Equals(pair.UserData.UID, selfUid, StringComparison.Ordinal);
AddMentionData(tokens, users, ambiguousTokens, pair.UserData.UID, pair.UserData.Alias, isSelf, pair, pair.UserData);
}
}
if (tokens.Count == 0)
{
return null;
}
return new MentionHighlightData(tokens, users);
}
private static List<MentionCandidate> FilterMentionCandidates(IEnumerable<MentionCandidate> candidates, string query)
{
string trimmed = query.Trim();
IEnumerable<MentionCandidate> filtered = candidates;
if (trimmed.Length > 0)
{
filtered = filtered.Where(candidate =>
candidate.Token.Contains(trimmed, StringComparison.OrdinalIgnoreCase)
|| candidate.DisplayName.Contains(trimmed, StringComparison.OrdinalIgnoreCase)
|| candidate.Uid.Contains(trimmed, StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrWhiteSpace(candidate.Note) && candidate.Note.Contains(trimmed, StringComparison.OrdinalIgnoreCase)));
}
List<MentionCandidate> result = filtered
.OrderBy(candidate => string.IsNullOrWhiteSpace(candidate.Note) ? candidate.DisplayName : candidate.Note, StringComparer.OrdinalIgnoreCase)
.ThenBy(candidate => candidate.DisplayName, StringComparer.OrdinalIgnoreCase)
.Take(MaxMentionSuggestions)
.ToList();
return result;
}
private static string BuildMentionLabel(MentionCandidate candidate)
{
string label = candidate.DisplayName;
if (!string.IsNullOrWhiteSpace(candidate.Note) && !string.Equals(candidate.Note, candidate.DisplayName, StringComparison.OrdinalIgnoreCase))
{
label = $"{candidate.Note} ({label})";
}
if (!string.Equals(candidate.Token, candidate.DisplayName, StringComparison.OrdinalIgnoreCase))
{
label = $"{label} [{candidate.Token}]";
}
return label;
}
private static string ApplyMentionToDraft(string draft, MentionQuery mentionQuery, string token, int maxLength, out int cursorPos)
{
string before = mentionQuery.StartIndex > 0 ? draft[..mentionQuery.StartIndex] : string.Empty;
string after = mentionQuery.EndIndex < draft.Length ? draft[mentionQuery.EndIndex..] : string.Empty;
string mentionText = "@" + token;
if (string.IsNullOrEmpty(after) || !char.IsWhiteSpace(after[0]))
{
mentionText += " ";
}
string updated = before + mentionText + after;
if (updated.Length > maxLength)
{
updated = updated[..maxLength];
}
cursorPos = Math.Min(before.Length + mentionText.Length, updated.Length);
return updated;
}
private unsafe int ChatInputCallback(ref ImGuiInputTextCallbackData data)
{
if (_pendingDraftCursorPos < 0)
{
return 0;
}
if (!string.Equals(_pendingDraftCursorChannelKey, _activeInputChannelKey, StringComparison.Ordinal))
{
return 0;
}
int clampedCursor = Math.Clamp(_pendingDraftCursorPos, 0, data.BufTextLen);
data.CursorPos = clampedCursor;
data.SelectionStart = clampedCursor;
data.SelectionEnd = clampedCursor;
_pendingDraftCursorPos = -1;
_pendingDraftCursorChannelKey = null;
return 0;
}
private float MeasureMessageHeight(
ChatChannelSnapshot channel,
ChatMessageEntry message,
bool showTimestamps,
float cursorStartX,
float contentMaxX,
float itemSpacing,
MentionHighlightData? mentionHighlightData,
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}: ";
}
return MeasureChatMessageHeight(prefix, payload.Message, lineStartX, contentMaxX, mentionHighlightData);
}
private float MeasureChatMessageHeight(string prefix, string message, float lineStartX, float contentMaxX, MentionHighlightData? mentionHighlightData)
{
var segments = BuildChatSegments(prefix, message, mentionHighlightData);
if (segments.Count == 0)
{
return ImGui.GetTextLineHeightWithSpacing();
}
var baseLineHeight = ImGui.GetTextLineHeight();
var emoteSize = baseLineHeight * GetEmoteScale();
var spacingY = ImGui.GetStyle().ItemSpacing.Y;
var availableWidth = Math.Max(1f, contentMaxX - lineStartX);
var remainingWidth = availableWidth;
var firstOnLine = true;
var lineHeight = baseLineHeight;
var totalHeight = 0f;
foreach (var segment in segments)
{
if (segment.IsLineBreak)
{
totalHeight += lineHeight + spacingY;
lineHeight = baseLineHeight;
firstOnLine = true;
remainingWidth = availableWidth;
continue;
}
if (segment.IsWhitespace && firstOnLine)
{
continue;
}
var segmentWidth = segment.IsEmote ? emoteSize : ImGui.CalcTextSize(segment.Text).X;
if (!firstOnLine)
{
if (segmentWidth > remainingWidth)
{
totalHeight += lineHeight + spacingY;
lineHeight = baseLineHeight;
firstOnLine = true;
remainingWidth = availableWidth;
if (segment.IsWhitespace)
{
continue;
}
}
}
if (segment.IsEmote)
{
lineHeight = MathF.Max(lineHeight, emoteSize);
}
remainingWidth -= segmentWidth;
firstOnLine = false;
}
totalHeight += lineHeight + spacingY;
return totalHeight;
}
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 GetEmoteScale()
=> Math.Clamp(_chatConfigService.Current.EmoteScale, MinEmoteScale, MaxEmoteScale);
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 MentionQuery(int StartIndex, int EndIndex, string Token);
private readonly record struct MentionCandidate(string Token, string DisplayName, string? Note, string Uid, bool IsSelf);
private readonly record struct MentionUserInfo(string Uid, UserData? UserData, Pair? Pair, bool IsSelf);
private readonly record struct MentionHighlightData(IReadOnlyDictionary<string, bool> Tokens, IReadOnlyDictionary<string, MentionUserInfo> Users);
private readonly record struct ChatSegment(string Text, IDalamudTextureWrap? Texture, string? EmoteName, bool IsEmote, bool IsWhitespace, bool IsLineBreak, bool IsMention, bool IsSelfMention)
{
public static ChatSegment FromText(string text, bool isWhitespace = false) => new(text, null, null, false, isWhitespace, false, false, false);
public static ChatSegment Emote(IDalamudTextureWrap texture, string name) => new(string.Empty, texture, name, true, false, false, false, false);
public static ChatSegment LineBreak() => new(string.Empty, null, null, false, false, true, false, false);
public static ChatSegment Mention(string text, bool isSelfMention) => new(text, null, null, false, false, false, true, isSelfMention);
}
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;
}
_activeInputChannelKey = channel.Key;
ImGui.InputText(inputId, ref draft, MaxMessageLength, ImGuiInputTextFlags.CallbackAlways, ChatInputCallback);
_activeInputChannelKey = null;
Vector2 inputMin = ImGui.GetItemRectMin();
Vector2 inputMax = ImGui.GetItemRectMax();
bool inputActive = ImGui.IsItemActive();
if (inputActive)
{
var drawList = ImGui.GetWindowDrawList();
var highlight = UIColors.Get("LightlessPurple").WithAlpha(0.35f);
var highlightU32 = ImGui.ColorConvertFloat4ToU32(highlight);
drawList.AddRect(inputMin, inputMax, highlightU32, style.FrameRounding, ImDrawFlags.None, Math.Max(1f, ImGuiHelpers.GlobalScale));
}
var enterPressed = ImGui.IsItemFocused()
&& (ImGui.IsKeyPressed(ImGuiKey.Enter) || ImGui.IsKeyPressed(ImGuiKey.KeypadEnter));
bool mentionHandled = false;
bool showMentionPopup = false;
bool popupAlreadyOpen = ImGui.IsPopupOpen(MentionPopupId, ImGuiPopupFlags.AnyPopupLevel);
bool mentionContextActive = (inputActive || popupAlreadyOpen) && channel.Type == ChatChannelType.Group;
if (mentionContextActive)
{
if (TryGetMentionQuery(draft, out MentionQuery mentionQuery))
{
PairUiSnapshot mentionSnapshot = _pairUiService.GetSnapshot();
List<MentionCandidate> mentionCandidates = BuildMentionCandidates(channel, mentionSnapshot, includeSelf: false);
List<MentionCandidate> filteredCandidates = FilterMentionCandidates(mentionCandidates, mentionQuery.Token);
if (filteredCandidates.Count > 0)
{
string mentionSelectionKey = $"{channel.Key}:{mentionQuery.Token}";
if (!string.Equals(_mentionSelectionKey, mentionSelectionKey, StringComparison.Ordinal))
{
_mentionSelectionKey = mentionSelectionKey;
_mentionSelectionIndex = 0;
}
else
{
_mentionSelectionIndex = Math.Clamp(_mentionSelectionIndex, 0, filteredCandidates.Count - 1);
}
if (ImGui.IsKeyPressed(ImGuiKey.DownArrow))
{
_mentionSelectionIndex = Math.Min(_mentionSelectionIndex + 1, filteredCandidates.Count - 1);
}
if (ImGui.IsKeyPressed(ImGuiKey.UpArrow))
{
_mentionSelectionIndex = Math.Max(_mentionSelectionIndex - 1, 0);
}
if (enterPressed || ImGui.IsKeyPressed(ImGuiKey.Tab))
{
int selectedIndex = Math.Clamp(_mentionSelectionIndex, 0, filteredCandidates.Count - 1);
MentionCandidate selected = filteredCandidates[selectedIndex];
draft = ApplyMentionToDraft(draft, mentionQuery, selected.Token, MaxMessageLength, out int cursorPos);
_pendingDraftCursorPos = cursorPos;
_pendingDraftCursorChannelKey = channel.Key;
_refocusChatInput = true;
_refocusChatInputKey = channel.Key;
enterPressed = false;
mentionHandled = true;
}
bool popupRequested = inputActive && !mentionHandled;
showMentionPopup = popupRequested || popupAlreadyOpen;
if (showMentionPopup)
{
float popupWidth = Math.Max(260f * ImGuiHelpers.GlobalScale, inputMax.X - inputMin.X);
ImGui.SetNextWindowPos(new Vector2(inputMin.X, inputMax.Y + style.ItemSpacing.Y), ImGuiCond.Always);
ImGui.SetNextWindowSizeConstraints(new Vector2(popupWidth, 0f), new Vector2(popupWidth, float.MaxValue));
}
if (popupRequested && !popupAlreadyOpen)
{
ImGui.OpenPopup(MentionPopupId);
}
const ImGuiWindowFlags mentionPopupFlags = ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoNavFocus | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoSavedSettings;
if (showMentionPopup && ImGui.BeginPopup(MentionPopupId, mentionPopupFlags))
{
float lineHeight = ImGui.GetTextLineHeightWithSpacing();
int visibleEntries = Math.Min(3, filteredCandidates.Count);
float desiredHeight = lineHeight * visibleEntries;
using (ImRaii.Child("##mention_list", new Vector2(-1f, desiredHeight), true))
{
for (int i = 0; i < filteredCandidates.Count; i++)
{
MentionCandidate candidate = filteredCandidates[i];
string label = BuildMentionLabel(candidate);
bool isSelected = i == _mentionSelectionIndex;
if (ImGui.Selectable(label, isSelected))
{
draft = ApplyMentionToDraft(draft, mentionQuery, candidate.Token, MaxMessageLength, out int cursorPos);
_pendingDraftCursorPos = cursorPos;
_pendingDraftCursorChannelKey = channel.Key;
_refocusChatInput = true;
_refocusChatInputKey = channel.Key;
enterPressed = false;
mentionHandled = true;
ImGui.CloseCurrentPopup();
break;
}
if (ImGui.IsItemHovered())
{
_mentionSelectionIndex = i;
}
}
}
ImGui.EndPopup();
}
}
else
{
_mentionSelectionKey = null;
_mentionSelectionIndex = -1;
if (popupAlreadyOpen && ImGui.BeginPopup(MentionPopupId))
{
ImGui.CloseCurrentPopup();
ImGui.EndPopup();
}
}
}
else
{
_mentionSelectionKey = null;
_mentionSelectionIndex = -1;
if (popupAlreadyOpen && ImGui.BeginPopup(MentionPopupId))
{
ImGui.CloseCurrentPopup();
ImGui.EndPopup();
}
}
}
else
{
_mentionSelectionKey = null;
_mentionSelectionIndex = -1;
if (popupAlreadyOpen && ImGui.BeginPopup(MentionPopupId))
{
ImGui.CloseCurrentPopup();
ImGui.EndPopup();
}
}
_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 (_forceExpandOnOpen && IsOpen)
{
Collapsed = null;
_forceExpandOnOpen = false;
}
if (IsOpen)
{
var metricsUpdated = TryUpdateWindowMetricsFromBase();
if (metricsUpdated)
{
var isCollapsed = IsLikelyCollapsed(_lastWindowSize);
UpdateCollapsedState(isCollapsed);
}
if (_isWindowCollapsed && _collapsedMessageCount > 0 && _hasWindowMetrics)
{
DrawCollapsedMessageBadge(ImGui.GetForegroundDrawList(), _lastWindowPos, _lastWindowSize);
}
}
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 HandleIncomingMessageForCollapsedBadge(ChatMessageEntry message)
{
if (!IsCountableIncomingMessage(message))
{
return;
}
var config = _chatConfigService.Current;
if (!IsOpen)
{
if (config.AutoOpenChatOnNewMessage && !ShouldHide())
{
IsOpen = true;
Collapsed = false;
CollapsedCondition = ImGuiCond.Appearing;
_forceExpandOnOpen = true;
}
return;
}
if (_isWindowCollapsed)
{
if (_collapsedMessageCount < CollapsedMessageCountDisplayCap + 1)
{
_collapsedMessageCount++;
}
}
}
private static bool IsCountableIncomingMessage(ChatMessageEntry message)
{
if (message.FromSelf || message.IsSystem)
{
return false;
}
return message.Payload?.Message is { Length: > 0 };
}
private void OnChatChannelMessageAdded(ChatChannelMessageAdded message)
{
var channelHidden = IsChannelHidden(message.ChannelKey);
if (!channelHidden && _selectedChannelKey is not null && string.Equals(_selectedChannelKey, message.ChannelKey, StringComparison.Ordinal))
{
_scrollToBottom = true;
}
if (!channelHidden)
{
HandleIncomingMessageForCollapsedBadge(message.Message);
}
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;
string? nextKey = channels.Count > 0 ? channels[0].Key : null;
if (!string.Equals(_selectedChannelKey, nextKey, StringComparison.Ordinal))
{
_selectedChannelKey = nextKey;
_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 IReadOnlyList<ChatChannelSnapshot> GetVisibleChannels(IReadOnlyList<ChatChannelSnapshot> channels)
{
Dictionary<string, bool> hiddenChannels = _chatConfigService.Current.HiddenChannels;
if (hiddenChannels.Count == 0)
{
return channels;
}
List<ChatChannelSnapshot> visibleChannels = new List<ChatChannelSnapshot>(channels.Count);
foreach (var channel in channels)
{
if (!hiddenChannels.TryGetValue(channel.Key, out var isHidden) || !isHidden)
{
visibleChannels.Add(channel);
}
}
return visibleChannels;
}
private bool IsChannelHidden(string channelKey)
=> _chatConfigService.Current.HiddenChannels.TryGetValue(channelKey, out var isHidden) && isHidden;
private void SetChannelHidden(string channelKey, bool hidden)
{
if (hidden)
{
_chatConfigService.Current.HiddenChannels[channelKey] = true;
}
else
{
_chatConfigService.Current.HiddenChannels.Remove(channelKey);
}
_chatConfigService.Save();
}
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;
var workSize = ImGui.GetMainViewport().WorkSize;
var minWidth = MathF.Min(420f * ImGuiHelpers.GlobalScale, workSize.X * 0.9f);
var minHeight = MathF.Min(360f * ImGuiHelpers.GlobalScale, workSize.Y * 0.85f);
var minSize = new Vector2(minWidth, minHeight);
var maxSize = new Vector2(
MathF.Max(minSize.X, workSize.X * 0.95f),
MathF.Max(minSize.Y, workSize.Y * 0.95f));
ImGui.SetNextWindowSizeConstraints(minSize, maxSize);
ImGui.SetNextWindowSize(minSize, ImGuiCond.Appearing);
if (!ImGui.BeginPopup(SettingsPopupId, popupFlags))
return;
ImGui.TextUnformatted("Chat Settings");
ImGui.Separator();
UiSharedService.Tab("ChatSettingsTabs", ChatSettingsTabOptions, ref _selectedChatSettingsTab);
ImGuiHelpers.ScaledDummy(5);
var chatConfig = _chatConfigService.Current;
switch (_selectedChatSettingsTab)
{
case ChatSettingsTab.General:
DrawChatSettingsGeneral(chatConfig);
break;
case ChatSettingsTab.Messages:
DrawChatSettingsMessages(chatConfig);
break;
case ChatSettingsTab.Notifications:
DrawChatSettingsNotifications(chatConfig);
break;
case ChatSettingsTab.Visibility:
DrawChatSettingsVisibility(chatConfig);
break;
case ChatSettingsTab.Window:
DrawChatSettingsWindow(chatConfig);
break;
}
ImGui.EndPopup();
}
private void DrawChatSettingsGeneral(ChatConfig chatConfig)
{
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.");
}
}
private void DrawChatSettingsMessages(ChatConfig chatConfig)
{
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 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.");
}
var emoteScale = Math.Clamp(chatConfig.EmoteScale, MinEmoteScale, MaxEmoteScale);
var emoteScaleChanged = ImGui.SliderFloat("Emote size", ref emoteScale, MinEmoteScale, MaxEmoteScale, "%.2fx");
var resetEmoteScale = ImGui.IsItemClicked(ImGuiMouseButton.Right);
if (resetEmoteScale)
{
emoteScale = 1.0f;
emoteScaleChanged = true;
}
if (emoteScaleChanged)
{
chatConfig.EmoteScale = emoteScale;
_chatConfigService.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Scales emotes relative to text height.\nRight-click to reset to default.");
}
ImGui.Separator();
ImGui.TextUnformatted("History");
ImGui.Separator();
bool persistHistory = chatConfig.PersistSyncshellHistory;
if (ImGui.Checkbox("Persist syncshell chat history", ref persistHistory))
{
chatConfig.PersistSyncshellHistory = persistHistory;
_chatConfigService.Save();
if (!persistHistory)
{
_zoneChatService.ClearPersistedSyncshellHistory(clearLoadedMessages: false);
}
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Stores the latest 200 syncshell messages on disk and restores them when chat loads.\nStored messages are considered stale and cannot be muted or reported.");
}
bool hasPersistedHistory = chatConfig.SyncshellChannelHistory.Count > 0;
using (ImRaii.Disabled(!hasPersistedHistory || !UiSharedService.CtrlPressed()))
{
if (ImGui.Button("Clear saved syncshell history"))
{
_zoneChatService.ClearPersistedSyncshellHistory(clearLoadedMessages: true);
}
}
UiSharedService.AttachToolTip("Clears saved syncshell chat history and loaded cached messages."
+ UiSharedService.TooltipSeparator + "Hold CTRL to enable this button");
}
private void DrawChatSettingsNotifications(ChatConfig chatConfig)
{
var notifyMentions = chatConfig.EnableMentionNotifications;
if (ImGui.Checkbox("Notify on mentions", ref notifyMentions))
{
chatConfig.EnableMentionNotifications = notifyMentions;
_chatConfigService.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Show a notification when someone mentions you in syncshell chat.");
}
var autoOpenOnMessage = chatConfig.AutoOpenChatOnNewMessage;
if (ImGui.Checkbox("Auto-open chat on new messages when closed", ref autoOpenOnMessage))
{
chatConfig.AutoOpenChatOnNewMessage = autoOpenOnMessage;
_chatConfigService.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Reopens the chat window when a new message arrives while it is closed.");
}
}
private void DrawChatSettingsVisibility(ChatConfig chatConfig)
{
ImGui.TextUnformatted("Channel Visibility");
ImGui.Separator();
IReadOnlyList<ChatChannelSnapshot> channels = _zoneChatService.GetChannelsSnapshot();
if (channels.Count == 0)
{
ImGui.TextDisabled("No chat channels available.");
}
else
{
ImGui.TextDisabled("Uncheck a channel to hide its tab.");
ImGui.TextDisabled("Hidden channels still receive messages.");
float maxListHeight = 200f * ImGuiHelpers.GlobalScale;
float listHeight = Math.Min(maxListHeight, channels.Count * ImGui.GetFrameHeightWithSpacing() + ImGui.GetStyle().ItemSpacing.Y);
using var child = ImRaii.Child("chat_channel_visibility_list", new Vector2(0f, listHeight), true);
if (child)
{
foreach (var channel in channels)
{
bool isVisible = !IsChannelHidden(channel.Key);
string prefix = channel.Type == ChatChannelType.Zone ? "Zone" : "Syncshell";
if (ImGui.Checkbox($"{prefix}: {channel.DisplayName}##{channel.Key}", ref isVisible))
{
SetChannelHidden(channel.Key, !isVisible);
}
}
}
}
ImGui.Separator();
ImGui.TextUnformatted("Chat Visibility");
ImGui.Separator();
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.");
}
}
private void DrawChatSettingsWindow(ChatConfig chatConfig)
{
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 opacity.");
}
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();
}
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)
{
ImGuiStylePtr style = ImGui.GetStyle();
Vector2 baseFramePadding = style.FramePadding;
float available = ImGui.GetContentRegionAvail().X;
float buttonHeight = ImGui.GetFrameHeight();
float arrowWidth = buttonHeight;
bool hasChannels = channels.Count > 0;
float scrollWidth = hasChannels ? Math.Max(0f, available - (arrowWidth * 2f + style.ItemSpacing.X * 2f)) : 0f;
if (hasChannels)
{
float minimumWidth = 120f * ImGuiHelpers.GlobalScale;
scrollWidth = Math.Max(scrollWidth, minimumWidth);
}
float scrollStep = scrollWidth > 0f ? scrollWidth * 0.9f : 120f;
float badgeSpacing = 4f * ImGuiHelpers.GlobalScale;
Vector2 badgePadding = new Vector2(4f, 1.5f) * ImGuiHelpers.GlobalScale;
bool showScrollbar = false;
if (hasChannels)
{
float totalWidth = 0f;
bool firstWidth = true;
foreach (ChatChannelSnapshot channel in channels)
{
if (!firstWidth)
{
totalWidth += style.ItemSpacing.X;
}
totalWidth += GetChannelTabWidth(channel, baseFramePadding, badgeSpacing, badgePadding);
firstWidth = false;
}
showScrollbar = totalWidth > scrollWidth;
}
float childHeight = buttonHeight + style.FramePadding.Y * 2f + (showScrollbar ? style.ScrollbarSize : 0f);
if (!hasChannels)
{
_pendingChannelScroll = null;
_channelScroll = 0f;
_channelScrollMax = 0f;
}
float prevScroll = hasChannels ? _channelScroll : 0f;
float 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 alignPushed = false;
if (hasChannels)
{
ImGui.PushStyleVar(ImGuiStyleVar.ButtonTextAlign, new Vector2(0f, 0.5f));
alignPushed = true;
}
using (var child = ImRaii.Child("channel_scroll", new Vector2(scrollWidth, childHeight), false, ImGuiWindowFlags.HorizontalScrollbar))
{
if (child)
{
bool dragActive = _dragChannelKey is not null && ImGui.IsMouseDragging(ImGuiMouseButton.Left);
bool hoveredTargetThisFrame = false;
bool first = true;
foreach (var channel in channels)
{
if (!first)
ImGui.SameLine();
bool isSelected = string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal);
bool showBadge = !isSelected && channel.UnreadCount > 0;
bool isZoneChannel = channel.Type == ChatChannelType.Zone;
(string Text, Vector2 TextSize, float Width, float Height)? badgeMetrics = null;
string channelLabel = GetChannelTabLabel(channel);
Vector4 normal = isSelected ? UIColors.Get("LightlessPurpleDefault") : UIColors.Get("ButtonDefault");
Vector4 hovered = isSelected
? UIColors.Get("LightlessPurple").WithAlpha(0.9f)
: UIColors.Get("ButtonDefault").WithAlpha(0.85f);
Vector4 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)
{
string badgeText = channel.UnreadCount > MaxBadgeDisplay
? $"{MaxBadgeDisplay}+"
: channel.UnreadCount.ToString(CultureInfo.InvariantCulture);
Vector2 badgeTextSize = ImGui.CalcTextSize(badgeText);
float badgeWidth = badgeTextSize.X + badgePadding.X * 2f;
float badgeHeight = badgeTextSize.Y + badgePadding.Y * 2f;
Vector2 customPadding = new Vector2(baseFramePadding.X + badgeWidth + badgeSpacing, baseFramePadding.Y);
ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, customPadding);
badgeMetrics = (badgeText, badgeTextSize, badgeWidth, badgeHeight);
}
bool 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();
}
bool isDragTarget = false;
if (ImGui.BeginDragDropTarget())
{
ImGuiDragDropFlags acceptFlags = ImGuiDragDropFlags.AcceptBeforeDelivery | ImGuiDragDropFlags.AcceptNoDrawDefaultRect;
ImGuiPayloadPtr 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();
}
bool 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);
}
}
ImDrawListPtr drawList = ImGui.GetWindowDrawList();
Vector2 itemMin = ImGui.GetItemRectMin();
Vector2 itemMax = ImGui.GetItemRectMax();
if (isHoveredDuringDrag)
{
Vector4 highlight = UIColors.Get("LightlessPurple").WithAlpha(0.35f);
uint 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)
{
Vector4 borderColor = UIColors.Get("LightlessOrange");
uint borderColorU32 = ImGui.ColorConvertFloat4ToU32(borderColor);
float borderThickness = Math.Max(1f, ImGuiHelpers.GlobalScale);
drawList.AddRect(itemMin, itemMax, borderColorU32, style.FrameRounding, ImDrawFlags.None, borderThickness);
}
if (showBadge && badgeMetrics is { } metrics)
{
float buttonSizeY = itemMax.Y - itemMin.Y;
Vector2 badgeMin = new Vector2(
itemMin.X + baseFramePadding.X,
itemMin.Y + (buttonSizeY - metrics.Height) * 0.5f);
Vector2 badgeMax = badgeMin + new Vector2(metrics.Width, metrics.Height);
Vector4 badgeColor = UIColors.Get("DimRed");
uint badgeColorU32 = ImGui.ColorConvertFloat4ToU32(badgeColor);
drawList.AddRectFilled(badgeMin, badgeMax, badgeColorU32, metrics.Height * 0.5f);
Vector2 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 float GetChannelTabWidth(ChatChannelSnapshot channel, Vector2 baseFramePadding, float badgeSpacing, Vector2 badgePadding)
{
string channelLabel = GetChannelTabLabel(channel);
float textWidth = ImGui.CalcTextSize(channelLabel).X;
bool isSelected = string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal);
bool showBadge = !isSelected && channel.UnreadCount > 0;
if (!showBadge)
{
return textWidth + baseFramePadding.X * 2f;
}
string badgeText = channel.UnreadCount > MaxBadgeDisplay
? $"{MaxBadgeDisplay}+"
: channel.UnreadCount.ToString(CultureInfo.InvariantCulture);
Vector2 badgeTextSize = ImGui.CalcTextSize(badgeText);
float badgeWidth = badgeTextSize.X + badgePadding.X * 2f;
float customPaddingX = baseFramePadding.X + badgeWidth + badgeSpacing;
return textWidth + customPaddingX * 2f;
}
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)
{
return true;
}
private void DrawChannelTabContextMenu(ChatChannelSnapshot channel)
{
if (ImGui.MenuItem("Hide Channel"))
{
SetChannelHidden(channel.Key, true);
if (string.Equals(_selectedChannelKey, channel.Key, StringComparison.Ordinal))
{
_selectedChannelKey = null;
_zoneChatService.SetActiveChannel(null);
}
ImGui.CloseCurrentPopup();
return;
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Unhide channels from Chat Settings -> Visibility.");
}
var preferNote = _chatConfigService.Current.PreferNotesForChannels.TryGetValue(channel.Key, out var value) && value;
var note = GetChannelNote(channel);
var hasNote = !string.IsNullOrWhiteSpace(note);
if (preferNote || hasNote)
{
ImGui.Separator();
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);
}
}