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