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