From 91393bf4a10d0d87c495e69ea67676ca120f4393 Mon Sep 17 00:00:00 2001 From: azyges Date: Sun, 30 Nov 2025 19:59:37 +0900 Subject: [PATCH] added chat report functionality and some other random stuff --- .../FileCache/TransientResourceManager.cs | 24 +- .../Interop/Ipc/Framework/IpcFramework.cs | 11 +- .../Interop/Ipc/IpcCallerPenumbra.cs | 2 +- .../Interop/Ipc/Penumbra/PenumbraRedraw.cs | 12 +- .../Configurations/TransientConfig.cs | 58 ++-- LightlessSync/Services/Chat/ChatModels.cs | 2 + .../Services/Chat/ZoneChatService.cs | 61 +++++ LightlessSync/UI/LightlessNotificationUI.cs | 14 +- LightlessSync/UI/UISharedService.cs | 36 ++- LightlessSync/UI/ZoneChatUi.cs | 250 ++++++++++++++++++ 10 files changed, 417 insertions(+), 53 deletions(-) diff --git a/LightlessSync/FileCache/TransientResourceManager.cs b/LightlessSync/FileCache/TransientResourceManager.cs index f808fa6..7f982a3 100644 --- a/LightlessSync/FileCache/TransientResourceManager.cs +++ b/LightlessSync/FileCache/TransientResourceManager.cs @@ -142,12 +142,21 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase return; } - var transientResources = resources.ToList(); - Logger.LogDebug("Persisting {count} transient resources", transientResources.Count); - List newlyAddedGamePaths = resources.Except(semiTransientResources, StringComparer.Ordinal).ToList(); - foreach (var gamePath in transientResources) + List transientResources; + lock (resources) { - semiTransientResources.Add(gamePath); + transientResources = resources.ToList(); + } + + Logger.LogDebug("Persisting {count} transient resources", transientResources.Count); + List newlyAddedGamePaths; + lock (semiTransientResources) + { + newlyAddedGamePaths = transientResources.Except(semiTransientResources, StringComparer.Ordinal).ToList(); + foreach (var gamePath in transientResources) + { + semiTransientResources.Add(gamePath); + } } bool saveConfig = false; @@ -180,7 +189,10 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase _configurationService.Save(); } - TransientResources[objectKind].Clear(); + lock (resources) + { + resources.Clear(); + } } public void RemoveTransientResource(ObjectKind objectKind, string path) diff --git a/LightlessSync/Interop/Ipc/Framework/IpcFramework.cs b/LightlessSync/Interop/Ipc/Framework/IpcFramework.cs index dbf1c15..a68367a 100644 --- a/LightlessSync/Interop/Ipc/Framework/IpcFramework.cs +++ b/LightlessSync/Interop/Ipc/Framework/IpcFramework.cs @@ -1,6 +1,7 @@ using Dalamud.Plugin; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; +using System.Linq; namespace LightlessSync.Interop.Ipc.Framework; @@ -107,7 +108,9 @@ public abstract class IpcServiceBase : DisposableMediatorSubscriberBase, IIpcSer try { var plugin = PluginInterface.InstalledPlugins - .FirstOrDefault(p => string.Equals(p.InternalName, Descriptor.InternalName, StringComparison.OrdinalIgnoreCase)); + .Where(p => string.Equals(p.InternalName, Descriptor.InternalName, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(p => p.IsLoaded) + .FirstOrDefault(); if (plugin == null) { @@ -119,7 +122,7 @@ public abstract class IpcServiceBase : DisposableMediatorSubscriberBase, IIpcSer return IpcConnectionState.VersionMismatch; } - if (!IsPluginEnabled()) + if (!IsPluginEnabled(plugin)) { return IpcConnectionState.PluginDisabled; } @@ -138,8 +141,8 @@ public abstract class IpcServiceBase : DisposableMediatorSubscriberBase, IIpcSer } } - protected virtual bool IsPluginEnabled() - => true; + protected virtual bool IsPluginEnabled(IExposedPlugin plugin) + => plugin.IsLoaded; protected virtual bool IsPluginReady() => true; diff --git a/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs b/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs index 4169e5c..c167654 100644 --- a/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs +++ b/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs @@ -122,7 +122,7 @@ public sealed class IpcCallerPenumbra : IpcServiceBase } } - protected override bool IsPluginEnabled() + protected override bool IsPluginEnabled(IExposedPlugin plugin) { try { diff --git a/LightlessSync/Interop/Ipc/Penumbra/PenumbraRedraw.cs b/LightlessSync/Interop/Ipc/Penumbra/PenumbraRedraw.cs index 7b3abd1..5d47d3a 100644 --- a/LightlessSync/Interop/Ipc/Penumbra/PenumbraRedraw.cs +++ b/LightlessSync/Interop/Ipc/Penumbra/PenumbraRedraw.cs @@ -51,9 +51,14 @@ public sealed class PenumbraRedraw : PenumbraBase return; } + var redrawSemaphore = _redrawManager.RedrawSemaphore; + var semaphoreAcquired = false; + try { - await _redrawManager.RedrawSemaphore.WaitAsync(token).ConfigureAwait(false); + await redrawSemaphore.WaitAsync(token).ConfigureAwait(false); + semaphoreAcquired = true; + await _redrawManager.PenumbraRedrawInternalAsync(logger, handler, applicationId, chara => { logger.LogDebug("[{ApplicationId}] Calling on IPC: PenumbraRedraw", applicationId); @@ -62,7 +67,10 @@ public sealed class PenumbraRedraw : PenumbraBase } finally { - _redrawManager.RedrawSemaphore.Release(); + if (semaphoreAcquired) + { + redrawSemaphore.Release(); + } } } diff --git a/LightlessSync/LightlessConfiguration/Configurations/TransientConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/TransientConfig.cs index c9a5f74..0bcb5ad 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/TransientConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/TransientConfig.cs @@ -13,6 +13,8 @@ public class TransientConfig : ILightlessConfiguration public Dictionary> JobSpecificCache { get; set; } = []; public Dictionary> JobSpecificPetCache { get; set; } = []; + private readonly object _cacheLock = new(); + public TransientPlayerConfig() { @@ -39,45 +41,51 @@ public class TransientConfig : ILightlessConfiguration public int RemovePath(string gamePath, ObjectKind objectKind) { - int removedEntries = 0; - if (objectKind == ObjectKind.Player) + lock (_cacheLock) { - if (GlobalPersistentCache.Remove(gamePath)) removedEntries++; - foreach (var kvp in JobSpecificCache) + int removedEntries = 0; + if (objectKind == ObjectKind.Player) { - if (kvp.Value.Remove(gamePath)) removedEntries++; + if (GlobalPersistentCache.Remove(gamePath)) removedEntries++; + foreach (var kvp in JobSpecificCache) + { + if (kvp.Value.Remove(gamePath)) removedEntries++; + } } - } - if (objectKind == ObjectKind.Pet) - { - foreach (var kvp in JobSpecificPetCache) + if (objectKind == ObjectKind.Pet) { - if (kvp.Value.Remove(gamePath)) removedEntries++; + foreach (var kvp in JobSpecificPetCache) + { + if (kvp.Value.Remove(gamePath)) removedEntries++; + } } + return removedEntries; } - return removedEntries; } public void AddOrElevate(uint jobId, string gamePath) { - // check if it's in the global cache, if yes, do nothing - if (GlobalPersistentCache.Contains(gamePath, StringComparer.Ordinal)) + lock (_cacheLock) { - return; - } + // check if it's in the global cache, if yes, do nothing + if (GlobalPersistentCache.Contains(gamePath, StringComparer.Ordinal)) + { + return; + } - if (ElevateIfNeeded(jobId, gamePath)) return; + if (ElevateIfNeeded(jobId, gamePath)) return; - // check if the jobid is already in the cache to start - if (!JobSpecificCache.TryGetValue(jobId, out var jobCache)) - { - JobSpecificCache[jobId] = jobCache = new(); - } + // check if the jobid is already in the cache to start + if (!JobSpecificCache.TryGetValue(jobId, out var jobCache)) + { + JobSpecificCache[jobId] = jobCache = new(); + } - // check if the path is already in the job specific cache - if (!jobCache.Contains(gamePath, StringComparer.Ordinal)) - { - jobCache.Add(gamePath); + // check if the path is already in the job specific cache + if (!jobCache.Contains(gamePath, StringComparer.Ordinal)) + { + jobCache.Add(gamePath); + } } } } diff --git a/LightlessSync/Services/Chat/ChatModels.cs b/LightlessSync/Services/Chat/ChatModels.cs index e9058e7..ba89084 100644 --- a/LightlessSync/Services/Chat/ChatModels.cs +++ b/LightlessSync/Services/Chat/ChatModels.cs @@ -19,3 +19,5 @@ public readonly record struct ChatChannelSnapshot( bool HasUnread, int UnreadCount, IReadOnlyList Messages); + +public readonly record struct ChatReportResult(bool Success, string? ErrorMessage); \ No newline at end of file diff --git a/LightlessSync/Services/Chat/ZoneChatService.cs b/LightlessSync/Services/Chat/ZoneChatService.cs index 4499cf8..9126436 100644 --- a/LightlessSync/Services/Chat/ZoneChatService.cs +++ b/LightlessSync/Services/Chat/ZoneChatService.cs @@ -17,6 +17,8 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS private const int MaxUnreadCount = 999; private const string ZoneUnavailableMessage = "Zone chat is only available in major cities."; private const string ZoneChannelKey = "zone"; + private const int MaxReportReasonLength = 500; + private const int MaxReportContextLength = 1000; private readonly ApiController _apiController; private readonly ChatConfigService _chatConfigService; @@ -244,6 +246,65 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS public Task ResolveParticipantAsync(ChatChannelDescriptor descriptor, string token) => _apiController.ResolveChatParticipant(new ChatParticipantResolveRequestDto(descriptor, token)); + public Task ReportMessageAsync(ChatChannelDescriptor descriptor, string messageId, string reason, string? additionalContext) + { + if (string.IsNullOrWhiteSpace(messageId)) + { + return Task.FromResult(new ChatReportResult(false, "Unable to locate the selected message.")); + } + + var trimmedReason = reason?.Trim() ?? string.Empty; + if (trimmedReason.Length == 0) + { + return Task.FromResult(new ChatReportResult(false, "Please describe why you are reporting this message.")); + } + + lock (_sync) + { + if (!_chatEnabled) + { + return Task.FromResult(new ChatReportResult(false, "Enable chat before reporting messages.")); + } + + if (!_isConnected) + { + return Task.FromResult(new ChatReportResult(false, "Connect to the chat server before reporting messages.")); + } + } + + if (trimmedReason.Length > MaxReportReasonLength) + { + trimmedReason = trimmedReason[..MaxReportReasonLength]; + } + + string? context = null; + if (!string.IsNullOrWhiteSpace(additionalContext)) + { + context = additionalContext.Trim(); + if (context.Length > MaxReportContextLength) + { + context = context[..MaxReportContextLength]; + } + } + + var normalizedDescriptor = descriptor.WithNormalizedCustomKey(); + return ReportMessageInternalAsync(normalizedDescriptor, messageId.Trim(), trimmedReason, context); + } + + private async Task ReportMessageInternalAsync(ChatChannelDescriptor descriptor, string messageId, string reason, string? additionalContext) + { + try + { + await _apiController.ReportChatMessage(new ChatReportSubmitDto(descriptor, messageId, reason, additionalContext)).ConfigureAwait(false); + return new ChatReportResult(true, null); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to submit chat report"); + return new ChatReportResult(false, "Failed to submit report. Please try again."); + } + } + public Task StartAsync(CancellationToken cancellationToken) { Mediator.Subscribe(this, _ => HandleLogin()); diff --git a/LightlessSync/UI/LightlessNotificationUI.cs b/LightlessSync/UI/LightlessNotificationUI.cs index bdbe8df..b280350 100644 --- a/LightlessSync/UI/LightlessNotificationUI.cs +++ b/LightlessSync/UI/LightlessNotificationUI.cs @@ -73,7 +73,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase else { _notifications.Add(notification); - _logger.LogDebug("Added new notification: {Title}", notification.Title); + _logger.LogTrace("Added new notification: {Title}", notification.Title); } if (!IsOpen) IsOpen = true; @@ -93,7 +93,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase existing.CreatedAt = DateTime.UtcNow; } - _logger.LogDebug("Updated existing notification: {Title}", updated.Title); + _logger.LogTrace("Updated existing notification: {Title}", updated.Title); } public void RemoveNotification(string id) @@ -576,7 +576,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase { var buttonWidth = CalculateActionButtonWidth(notification.Actions.Count, availableWidth); - _logger.LogDebug("Drawing {ActionCount} notification actions, buttonWidth: {ButtonWidth}, availableWidth: {AvailableWidth}", + _logger.LogTrace("Drawing {ActionCount} notification actions, buttonWidth: {ButtonWidth}, availableWidth: {AvailableWidth}", notification.Actions.Count, buttonWidth, availableWidth); var startX = ImGui.GetCursorPosX(); @@ -606,7 +606,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase private void DrawActionButton(LightlessNotificationAction action, LightlessNotification notification, float alpha, float buttonWidth) { - _logger.LogDebug("Drawing action button: {ActionId} - {ActionLabel}, width: {ButtonWidth}", action.Id, action.Label, buttonWidth); + _logger.LogTrace("Drawing action button: {ActionId} - {ActionLabel}, width: {ButtonWidth}", action.Id, action.Label, buttonWidth); var buttonColor = action.Color; buttonColor.W *= alpha; @@ -633,15 +633,15 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase buttonPressed = ImGui.Button(action.Label, new Vector2(buttonWidth, 0)); } - _logger.LogDebug("Button {ActionId} pressed: {ButtonPressed}", action.Id, buttonPressed); + _logger.LogTrace("Button {ActionId} pressed: {ButtonPressed}", action.Id, buttonPressed); if (buttonPressed) { try { - _logger.LogDebug("Executing action: {ActionId}", action.Id); + _logger.LogTrace("Executing action: {ActionId}", action.Id); action.OnClick(notification); - _logger.LogDebug("Action executed successfully: {ActionId}", action.Id); + _logger.LogTrace("Action executed successfully: {ActionId}", action.Id); } catch (Exception ex) { diff --git a/LightlessSync/UI/UISharedService.cs b/LightlessSync/UI/UISharedService.cs index 2875acb..537d1bf 100644 --- a/LightlessSync/UI/UISharedService.cs +++ b/LightlessSync/UI/UISharedService.cs @@ -15,6 +15,7 @@ using Dalamud.Plugin.Services; using Dalamud.Utility; using LightlessSync.FileCache; using LightlessSync.Interop.Ipc; +using LightlessSync.Interop.Ipc.Framework; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.Localization; @@ -975,36 +976,36 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase ImGui.SameLine(150); ColorText("Penumbra", GetBoolColor(_penumbraExists)); - AttachToolTip($"Penumbra is " + (_penumbraExists ? "available and up to date." : "unavailable or not up to date.")); + AttachToolTip(BuildPluginTooltip("Penumbra", _penumbraExists, _ipcManager.Penumbra.State)); ImGui.SameLine(); ColorText("Glamourer", GetBoolColor(_glamourerExists)); - AttachToolTip($"Glamourer is " + (_glamourerExists ? "available and up to date." : "unavailable or not up to date.")); + AttachToolTip(BuildPluginTooltip("Glamourer", _glamourerExists, _ipcManager.Glamourer.State)); ImGui.TextUnformatted("Optional Plugins:"); ImGui.SameLine(150); ColorText("SimpleHeels", GetBoolColor(_heelsExists)); - AttachToolTip($"SimpleHeels is " + (_heelsExists ? "available and up to date." : "unavailable or not up to date.")); + AttachToolTip(BuildPluginTooltip("SimpleHeels", _heelsExists, _ipcManager.Heels.State)); ImGui.SameLine(); ColorText("Customize+", GetBoolColor(_customizePlusExists)); - AttachToolTip($"Customize+ is " + (_customizePlusExists ? "available and up to date." : "unavailable or not up to date.")); + AttachToolTip(BuildPluginTooltip("Customize+", _customizePlusExists, _ipcManager.CustomizePlus.State)); ImGui.SameLine(); ColorText("Honorific", GetBoolColor(_honorificExists)); - AttachToolTip($"Honorific is " + (_honorificExists ? "available and up to date." : "unavailable or not up to date.")); + AttachToolTip(BuildPluginTooltip("Honorific", _honorificExists, _ipcManager.Honorific.State)); ImGui.SameLine(); ColorText("Moodles", GetBoolColor(_moodlesExists)); - AttachToolTip($"Moodles is " + (_moodlesExists ? "available and up to date." : "unavailable or not up to date.")); + AttachToolTip(BuildPluginTooltip("Moodles", _moodlesExists, _ipcManager.Moodles.State)); ImGui.SameLine(); ColorText("PetNicknames", GetBoolColor(_petNamesExists)); - AttachToolTip($"PetNicknames is " + (_petNamesExists ? "available and up to date." : "unavailable or not up to date.")); + AttachToolTip(BuildPluginTooltip("PetNicknames", _petNamesExists, _ipcManager.PetNames.State)); ImGui.SameLine(); ColorText("Brio", GetBoolColor(_brioExists)); - AttachToolTip($"Brio is " + (_brioExists ? "available and up to date." : "unavailable or not up to date.")); + AttachToolTip(BuildPluginTooltip("Brio", _brioExists, _ipcManager.Brio.State)); if (!_penumbraExists || !_glamourerExists) { @@ -1015,6 +1016,25 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase return true; } + private static string BuildPluginTooltip(string pluginName, bool isAvailable, IpcConnectionState state) + { + var availability = isAvailable ? "available and up to date." : "unavailable or not up to date."; + return $"{pluginName} is {availability}{Environment.NewLine}IPC State: {DescribeIpcState(state)}"; + } + + private static string DescribeIpcState(IpcConnectionState state) + => state switch + { + IpcConnectionState.Unknown => "Not evaluated yet", + IpcConnectionState.MissingPlugin => "Plugin not installed", + IpcConnectionState.VersionMismatch => "Installed version below required minimum", + IpcConnectionState.PluginDisabled => "Plugin installed but disabled", + IpcConnectionState.NotReady => "Plugin is not ready yet", + IpcConnectionState.Available => "Available", + IpcConnectionState.Error => "Error occurred while checking IPC", + _ => state.ToString() + }; + public int DrawServiceSelection(bool selectOnChange = false, bool showConnect = true) { string[] comboEntries = _serverConfigurationManager.GetServerNames(); diff --git a/LightlessSync/UI/ZoneChatUi.cs b/LightlessSync/UI/ZoneChatUi.cs index d8ac877..c2fcf02 100644 --- a/LightlessSync/UI/ZoneChatUi.cs +++ b/LightlessSync/UI/ZoneChatUi.cs @@ -29,9 +29,12 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase { private const string ChatDisabledStatus = "Chat services disabled"; private const string SettingsPopupId = "zone_chat_settings_popup"; + private const string ReportPopupId = "Report Message##zone_chat_report_popup"; private const float DefaultWindowOpacity = .97f; private const float MinWindowOpacity = 0.05f; private const float MaxWindowOpacity = 1f; + private const int ReportReasonMaxLength = 500; + private const int ReportContextMaxLength = 1000; private readonly UiSharedService _uiSharedService; private readonly ZoneChatService _zoneChatService; @@ -50,6 +53,15 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private float? _pendingChannelScroll; private float _channelScroll; private float _channelScrollMax; + 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; public ZoneChatUi( ILogger logger, @@ -112,6 +124,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase DrawConnectionControls(); var channels = _zoneChatService.GetChannelsSnapshot(); + DrawReportPopup(); if (channels.Count == 0) { @@ -482,6 +495,221 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.PopStyleVar(); } + private void DrawReportPopup() + { + if (!_reportPopupOpen) + return; + + var desiredPopupSize = new Vector2(520f * ImGuiHelpers.GlobalScale, 0f); + ImGui.SetNextWindowSize(desiredPopupSize, ImGuiCond.Always); + if (_reportPopupRequested) + { + ImGui.OpenPopup(ReportPopupId); + _reportPopupRequested = false; + } + else if (!ImGui.IsPopupOpen(ReportPopupId, ImGuiPopupFlags.AnyPopupLevel)) + { + ImGui.OpenPopup(ReportPopupId); + } + + var popupFlags = UiSharedService.PopupWindowFlags | ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoSavedSettings; + if (!ImGui.BeginPopupModal(ReportPopupId, popupFlags)) + return; + + if (_reportTargetChannel is not { } channel || _reportTargetMessage is not { } message) + { + CloseReportPopup(); + ImGui.EndPopup(); + return; + } + + if (_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: {message.Payload.SentAtUtc.ToLocalTime().ToString("g", CultureInfo.CurrentCulture)}"); + + ImGui.Separator(); + ImGui.PushTextWrapPos(ImGui.GetWindowContentRegionMax().X); + ImGui.TextWrapped(message.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))) + { + if (_reportReason.Length > ReportReasonMaxLength) + { + _reportReason = _reportReason[..(int)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))) + { + if (_reportAdditionalContext.Length > ReportContextMaxLength) + { + _reportAdditionalContext = _reportAdditionalContext[..(int)ReportContextMaxLength]; + } + } + + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); + ImGui.TextUnformatted($"{_reportAdditionalContext.Length}/{ReportContextMaxLength}"); + ImGui.PopStyleColor(); + + if (!string.IsNullOrEmpty(_reportError)) + { + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); + ImGui.TextWrapped(_reportError); + ImGui.PopStyleColor(); + } + + if (_reportSubmitting) + { + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); + ImGui.TextUnformatted("Submitting report..."); + ImGui.PopStyleColor(); + } + + ImGui.Separator(); + var style = ImGui.GetStyle(); + var availableWidth = Math.Max(0f, ImGui.GetContentRegionAvail().X); + var buttonWidth = Math.Max(100f * ImGuiHelpers.GlobalScale, (availableWidth - style.ItemSpacing.X) / 2f); + var canSubmit = !_reportSubmitting && _reportReason.Trim().Length > 0; + + using (ImRaii.Disabled(!canSubmit)) + { + if (ImGui.Button("Submit Report", new Vector2(buttonWidth, 0f)) && canSubmit) + { + BeginReportSubmission(channel, message); + } + } + + ImGui.SameLine(); + if (ImGui.Button("Cancel", new Vector2(buttonWidth, 0f))) + { + CloseReportPopup(); + } + + ImGui.EndPopup(); + } + + private void OpenReportPopup(ChatChannelSnapshot channel, ChatMessageEntry message) + { + _reportTargetChannel = channel; + _reportTargetMessage = message; + _logger.LogDebug("Opening report popup for channel {ChannelKey}, message {MessageId}", channel.Key, message.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; + + 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 = message.Payload.MessageId; + if (string.IsNullOrWhiteSpace(messageId)) + { + _reportSubmitting = false; + _reportError = "Unable to report this message."; + _logger.LogWarning("Report submission aborted: missing message id for channel {ChannelKey}", channel.Key); + return; + } + + _logger.LogDebug("Submitting chat report for channel {ChannelKey}, message {MessageId}", channel.Key, messageId); + _ = Task.Run(async () => + { + ChatReportResult result; + try + { + result = await _zoneChatService.ReportMessageAsync(descriptor, messageId, trimmedReason, trimmedContext).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to submit chat report"); + result = new ChatReportResult(false, "Failed to submit report. Please try again."); + } + + _reportSubmissionResult = result; + if (result.Success) + { + _logger.LogInformation("Chat report submitted successfully for channel {ChannelKey}, message {MessageId}", channel.Key, messageId); + } + else + { + _logger.LogWarning("Chat report submission failed for channel {ChannelKey}, message {MessageId}: {Error}", channel.Key, messageId, result.ErrorMessage); + } + }); + } + + private void CloseReportPopup() + { + _reportPopupOpen = false; + _reportPopupRequested = false; + ResetReportPopupState(); + ImGui.CloseCurrentPopup(); + } + + private void ResetReportPopupState() + { + _reportTargetChannel = null; + _reportTargetMessage = null; + _reportReason = string.Empty; + _reportAdditionalContext = string.Empty; + _reportError = null; + _reportSubmissionResult = null; + _reportSubmitting = false; + _reportPopupRequested = false; + } + private bool TrySendDraft(ChatChannelSnapshot channel, string draft) { var trimmed = draft.Trim(); @@ -513,6 +741,11 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase { yield return viewProfile; } + + if (TryCreateReportMessageAction(channel, message, out var reportAction)) + { + yield return reportAction; + } } private bool TryCreateCopyMessageAction(ChatMessageEntry message, out ChatMessageContextAction action) @@ -578,6 +811,23 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } } + private bool TryCreateReportMessageAction(ChatChannelSnapshot channel, ChatMessageEntry message, out ChatMessageContextAction action) + { + action = default; + if (message.FromSelf) + return false; + + if (string.IsNullOrWhiteSpace(message.Payload.MessageId)) + return false; + + action = new ChatMessageContextAction( + "Report Message", + true, + () => OpenReportPopup(channel, message)); + + return true; + } + private Task OpenStandardProfileAsync(UserData user) { _profileManager.GetLightlessProfile(user);