feat(input-bar): wire auto-translate tab picker + payload-replace on send
v1.7.0 component-layer refactor removed ChatLogWindow.cs (which housed the auto-translate popup) and dropped Ui/AutoCompleteInfo.cs without migrating the logic into the new InputBar component — v2.x spec §3 said "LÖSCHEN + Logik migrieren", but only the deletion happened. Result: Tab key did nothing in v1.7.1, and even manually typed <at:group,key> tokens were never resolved into real auto-translate payloads on send. Root cause confirmed empirically via VN-1 diagnostic build (_logger.LogDebug in SlashCommandCallback proved CallbackCompletion fires on Tab once the flag is set). Following the diagnose-zuerst pattern established by Issue #3 to avoid the source-code-only hypothesis trap from Issue #2. Migration follows v1.5.6 ChatLogWindow.DrawAutoComplete + ChatTwo upstream AutoCompleteHandler patterns, but ported to v1.7.0 stil: - ImGuiInputTextFlags extended with CallbackCompletion (Tab trigger) and CallbackAlways (cursor restore via _activatePos analog v1.5.6 ActivatePos) - SlashCommandCallback now dispatches three branches: CallbackAlways (cursor restore), CallbackCompletion (Tab → word-boundary search via Encoding.UTF8.GetString on the byte span, char-offset DTO construction to avoid the byte-vs-char drift in v1.5.6's raw pointer arithmetic), CallbackEdit (existing slash-command help detection, now properly scoped) - 7 new private state fields (_autoCompleteInfo, _autoCompleteOpen, _autoCompleteList, _fixCursor, _autoCompleteSelection, _autoCompleteShouldScroll, _activatePos) - DrawAutoCompletePopup renders the picker at the end of Draw(): IsWindowAppearing seeds _fixCursor + focus, ListClipper-wrapper from Util/SearchSelector.cs (IDisposable, automatic Destroy) for the result list, Ctrl+0-9 quick-pick, Enter/Escape handling, char-splice commit (_pendingMessage = before + replacement + after) - AutoCompleteCallback handles popup-input-field fix-cursor seeding, Up/Down navigation with wrap-around, Tab cycle in the default case - TrySend now runs AutoTranslate.ReplaceWithPayload(ref bytes) and sends via ChatBox.SendMessageUnsafe(byte[]) with a manual 500-byte guard, because SendMessage(string) would route through SanitiseText which destroys the binary SeString macro bytes that ReplaceWithPayload emits - AutoCompleteInfo DTO added as sealed internal companion type at the end of InputBar.cs (15 LOC, exclusively consumed by InputBar); ToComplete is a mutable field rather than auto-property so it can be passed as ref to ImGui.InputTextWithHint without CS0206 Verified in-game (Flo): Tab on empty input opens picker with full list, "fire" + Tab filters correctly, Up/Down/Tab navigate, Enter commits <at:group,key>, send resolves to real auto-translate payload in chat, Ctrl+0-9 quick-pick works, Escape closes without commit. dotnet build clean, dotnet csharpier check clean. Single minor plan-drift: scroll-to-selected uses ImGui.SetScrollY(selection * lineHeight) instead of SetScrollFromPosY(clipper.StartPosY) because the local ListClipper-wrapper does not expose StartPosY — same UX effect.
This commit is contained in:
@@ -2,9 +2,11 @@ using System.Numerics;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Dalamud.Bindings.ImGui;
|
using Dalamud.Bindings.ImGui;
|
||||||
using Dalamud.Interface;
|
using Dalamud.Interface;
|
||||||
|
using Dalamud.Interface.Utility;
|
||||||
using Dalamud.Interface.Utility.Raii;
|
using Dalamud.Interface.Utility.Raii;
|
||||||
using HellionChat.Code;
|
using HellionChat.Code;
|
||||||
using HellionChat.GameFunctions;
|
using HellionChat.GameFunctions;
|
||||||
|
using HellionChat.Resources;
|
||||||
using HellionChat.Themes;
|
using HellionChat.Themes;
|
||||||
using HellionChat.Ui;
|
using HellionChat.Ui;
|
||||||
using HellionChat.Ui.StyleEngine;
|
using HellionChat.Ui.StyleEngine;
|
||||||
@@ -40,6 +42,21 @@ internal sealed class InputBar
|
|||||||
private bool _wasInputTextHovered;
|
private bool _wasInputTextHovered;
|
||||||
private bool? _isFocusedOverride; // Test-only; null = honour per-frame Draw() value.
|
private bool? _isFocusedOverride; // Test-only; null = honour per-frame Draw() value.
|
||||||
|
|
||||||
|
// Auto-translate popup state — lives here because the popup lifecycle is
|
||||||
|
// tightly coupled to the input callback and the pending message buffer.
|
||||||
|
private const string AutoCompleteId = "##hellion-at-complete";
|
||||||
|
private AutoCompleteInfo? _autoCompleteInfo;
|
||||||
|
private bool _autoCompleteOpen;
|
||||||
|
private List<AutoTranslateEntry>? _autoCompleteList;
|
||||||
|
private bool _fixCursor;
|
||||||
|
private int _autoCompleteSelection;
|
||||||
|
private bool _autoCompleteShouldScroll;
|
||||||
|
|
||||||
|
// Cursor restore position after popup commit; -1 = no pending restore.
|
||||||
|
// The main InputText sees the write inside its CallbackAlways branch on the
|
||||||
|
// next frame because ImGui only honours data.CursorPos writes from a callback.
|
||||||
|
private int _activatePos = -1;
|
||||||
|
|
||||||
public bool Activate;
|
public bool Activate;
|
||||||
|
|
||||||
public InputBar(
|
public InputBar(
|
||||||
@@ -146,6 +163,10 @@ internal sealed class InputBar
|
|||||||
var inserted = _symbolPicker.DrawAndConsume();
|
var inserted = _symbolPicker.DrawAndConsume();
|
||||||
if (inserted is not null && _pendingMessage.Length + inserted.Length <= BufferCapacity)
|
if (inserted is not null && _pendingMessage.Length + inserted.Length <= BufferCapacity)
|
||||||
_pendingMessage += inserted;
|
_pendingMessage += inserted;
|
||||||
|
|
||||||
|
// Auto-translate popup runs after all other popups so the OpenPopup
|
||||||
|
// anchor lands on the InputText item we just drew.
|
||||||
|
DrawAutoCompletePopup();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ResolvePillLabel(Tab? tab, bool isTell)
|
private static string ResolvePillLabel(Tab? tab, bool isTell)
|
||||||
@@ -231,7 +252,10 @@ internal sealed class InputBar
|
|||||||
"##hellion-input",
|
"##hellion-input",
|
||||||
ref _pendingMessage,
|
ref _pendingMessage,
|
||||||
BufferCapacity,
|
BufferCapacity,
|
||||||
ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.CallbackEdit,
|
ImGuiInputTextFlags.EnterReturnsTrue
|
||||||
|
| ImGuiInputTextFlags.CallbackEdit
|
||||||
|
| ImGuiInputTextFlags.CallbackCompletion
|
||||||
|
| ImGuiInputTextFlags.CallbackAlways,
|
||||||
SlashCommandCallback
|
SlashCommandCallback
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -243,18 +267,55 @@ internal sealed class InputBar
|
|||||||
_wasInputTextHovered = ImGui.IsItemHovered();
|
_wasInputTextHovered = ImGui.IsItemHovered();
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.5.6 character-level slash-detect: fires on every edit so CommandHelpWindow
|
// Dispatches across three ImGui callback events: CallbackAlways (cursor
|
||||||
// stays in sync with what the user is typing without a per-frame poll.
|
// restore after popup commit), CallbackCompletion (Tab opens the auto-
|
||||||
|
// translate picker), CallbackEdit (slash-command help window sync).
|
||||||
private int SlashCommandCallback(scoped ref ImGuiInputTextCallbackData data)
|
private int SlashCommandCallback(scoped ref ImGuiInputTextCallbackData data)
|
||||||
{
|
{
|
||||||
|
// Cursor restore after popup commit. _activatePos is set in
|
||||||
|
// DrawAutoCompletePopup to "behind the inserted <at:...> token";
|
||||||
|
// we replay it on the next CallbackAlways frame because ImGui only
|
||||||
|
// honours data.CursorPos writes from inside a callback.
|
||||||
|
if (data.EventFlag == ImGuiInputTextFlags.CallbackAlways)
|
||||||
|
{
|
||||||
|
if (_activatePos != -1)
|
||||||
|
{
|
||||||
|
data.CursorPos = _activatePos;
|
||||||
|
data.SelectionStart = data.SelectionEnd = _activatePos;
|
||||||
|
_activatePos = -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.EventFlag == ImGuiInputTextFlags.CallbackCompletion)
|
||||||
|
{
|
||||||
|
// CursorPos is a BYTE offset into the UTF-8 buffer. We decode the
|
||||||
|
// prefix up to the cursor as a managed string so every offset in
|
||||||
|
// AutoCompleteInfo is a CHAR offset — _pendingMessage is a managed
|
||||||
|
// string and gets spliced via char-indices in DrawAutoCompletePopup.
|
||||||
|
// Mixing byte- and char-offsets crashes on multi-byte UTF-8 (CJK,
|
||||||
|
// emoji) before the cursor.
|
||||||
|
var prefix = Encoding.UTF8.GetString(data.BufTextSpan[..data.CursorPos]);
|
||||||
|
var spaceIdx = prefix.LastIndexOf(' ');
|
||||||
|
var wordStart = spaceIdx < 0 ? 0 : spaceIdx + 1;
|
||||||
|
var word = prefix[wordStart..];
|
||||||
|
_autoCompleteInfo = new AutoCompleteInfo(word, wordStart, prefix.Length);
|
||||||
|
_autoCompleteOpen = true;
|
||||||
|
_autoCompleteSelection = 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallbackEdit (or any remaining event): v1.5.6 character-level slash
|
||||||
|
// detection keeps CommandHelpWindow in sync with what the user is
|
||||||
|
// typing without a per-frame poll.
|
||||||
_commandHelpWindow.IsOpen = false;
|
_commandHelpWindow.IsOpen = false;
|
||||||
|
|
||||||
var text = Encoding.UTF8.GetString(data.BufTextSpan);
|
var text = Encoding.UTF8.GetString(data.BufTextSpan);
|
||||||
if (!text.StartsWith('/'))
|
if (!text.StartsWith('/'))
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
var spaceIdx = text.IndexOf(' ');
|
var slashSpaceIdx = text.IndexOf(' ');
|
||||||
var command = spaceIdx > 0 ? text[..spaceIdx] : text;
|
var command = slashSpaceIdx > 0 ? text[..slashSpaceIdx] : text;
|
||||||
|
|
||||||
// Keys in CommandManager.Commands include the leading slash.
|
// Keys in CommandManager.Commands include the leading slash.
|
||||||
if (AllCommands.TryGetValue(command, out var textCommand))
|
if (AllCommands.TryGetValue(command, out var textCommand))
|
||||||
@@ -290,7 +351,21 @@ internal sealed class InputBar
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ChatBox.SendMessage(toSend);
|
// AutoTranslate produces binary SeString macro bytes; SendMessage(string)
|
||||||
|
// would run SanitiseText over them and destroy the payload encoding.
|
||||||
|
// SendMessageUnsafe bypasses ValidateMessage entirely, so we mirror its
|
||||||
|
// 500-byte guard manually.
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(toSend);
|
||||||
|
AutoTranslate.ReplaceWithPayload(ref bytes);
|
||||||
|
if (bytes.Length > 500)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"TrySend dropped: message exceeds 500 bytes ({Length}) after AT-resolve.",
|
||||||
|
bytes.Length
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ChatBox.SendMessageUnsafe(bytes);
|
||||||
_pendingMessage = string.Empty;
|
_pendingMessage = string.Empty;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -341,4 +416,225 @@ internal sealed class InputBar
|
|||||||
// Test-only hook; do not call from production code. Pass null to release the
|
// Test-only hook; do not call from production code. Pass null to release the
|
||||||
// override and let Draw()'s ImGui.IsItemFocused() result take over again.
|
// override and let Draw()'s ImGui.IsItemFocused() result take over again.
|
||||||
internal void TestSetFocusedForSelfTest(bool? value) => _isFocusedOverride = value;
|
internal void TestSetFocusedForSelfTest(bool? value) => _isFocusedOverride = value;
|
||||||
|
|
||||||
|
private void DrawAutoCompletePopup()
|
||||||
|
{
|
||||||
|
if (_autoCompleteInfo == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Match cache: rebuilt on every search-field edit below. Lazy init here
|
||||||
|
// covers the first frame after Tab opens the popup.
|
||||||
|
_autoCompleteList ??= AutoTranslate.Matching(
|
||||||
|
_autoCompleteInfo.ToComplete,
|
||||||
|
Plugin.Config.SortAutoTranslate
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_autoCompleteOpen)
|
||||||
|
{
|
||||||
|
ImGui.OpenPopup(AutoCompleteId);
|
||||||
|
_autoCompleteOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui.SetNextWindowSize(new Vector2(400, 300) * ImGuiHelpers.GlobalScale);
|
||||||
|
using var popup = ImRaii.Popup(AutoCompleteId);
|
||||||
|
if (!popup.Success)
|
||||||
|
{
|
||||||
|
// Popup just closed (Escape, click-outside, or commit). Schedule the
|
||||||
|
// main InputText to re-focus and restore the cursor to the end of
|
||||||
|
// the original word so the user can keep typing without manual repositioning.
|
||||||
|
if (_activatePos == -1)
|
||||||
|
_activatePos = _autoCompleteInfo.EndPos;
|
||||||
|
|
||||||
|
_autoCompleteInfo = null;
|
||||||
|
_autoCompleteList = null;
|
||||||
|
Activate = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui.SetNextItemWidth(-1);
|
||||||
|
if (
|
||||||
|
ImGui.InputTextWithHint(
|
||||||
|
"##hellion-at-search",
|
||||||
|
Language.AutoTranslate_Search_Hint,
|
||||||
|
ref _autoCompleteInfo.ToComplete,
|
||||||
|
256,
|
||||||
|
ImGuiInputTextFlags.CallbackAlways | ImGuiInputTextFlags.CallbackHistory,
|
||||||
|
AutoCompleteCallback
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// User typed in the search field: refresh matches and reset selection.
|
||||||
|
_autoCompleteList = AutoTranslate.Matching(
|
||||||
|
_autoCompleteInfo.ToComplete,
|
||||||
|
Plugin.Config.SortAutoTranslate
|
||||||
|
);
|
||||||
|
_autoCompleteSelection = 0;
|
||||||
|
_autoCompleteShouldScroll = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+0..9 jump-pick: 1..9 maps to index 0..8, 0 maps to index 9 (top-row layout).
|
||||||
|
var selected = -1;
|
||||||
|
if (ImGui.IsItemActive() && ImGui.GetIO().KeyCtrl)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < 10 && i < _autoCompleteList.Count; i++)
|
||||||
|
{
|
||||||
|
var num = (i + 1) % 10;
|
||||||
|
var key = ImGuiKey.Key0 + num;
|
||||||
|
var key2 = ImGuiKey.Keypad0 + num;
|
||||||
|
if (ImGui.IsKeyDown(key) || ImGui.IsKeyDown(key2))
|
||||||
|
selected = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ImGui.IsItemDeactivated())
|
||||||
|
{
|
||||||
|
if (ImGui.IsKeyDown(ImGuiKey.Escape))
|
||||||
|
{
|
||||||
|
ImGui.CloseCurrentPopup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var enter = ImGui.IsKeyDown(ImGuiKey.Enter) || ImGui.IsKeyDown(ImGuiKey.KeypadEnter);
|
||||||
|
if (_autoCompleteList.Count > 0 && enter)
|
||||||
|
selected = _autoCompleteSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First-frame focus: hand keyboard focus back to the search field and
|
||||||
|
// ask AutoCompleteCallback to drop the caret at the end of the prefix.
|
||||||
|
if (ImGui.IsWindowAppearing())
|
||||||
|
{
|
||||||
|
_fixCursor = true;
|
||||||
|
ImGui.SetKeyboardFocusHere(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var child = ImRaii.Child(
|
||||||
|
"##hellion-at-list",
|
||||||
|
Vector2.Zero,
|
||||||
|
false,
|
||||||
|
ImGuiWindowFlags.HorizontalScrollbar
|
||||||
|
);
|
||||||
|
if (!child.Success)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// ListClipper wrapper (Util/SearchSelector.cs) is IDisposable, so the
|
||||||
|
// using-statement frees the unmanaged ImGuiListClipper for us — without
|
||||||
|
// it the block would leak per render frame.
|
||||||
|
using var clipper = new ListClipper(_autoCompleteList.Count);
|
||||||
|
foreach (var i in clipper.Rows)
|
||||||
|
{
|
||||||
|
var entry = _autoCompleteList[i];
|
||||||
|
var highlight = _autoCompleteSelection == i;
|
||||||
|
var clicked =
|
||||||
|
ImGui.Selectable($"{entry.Text}##{entry.Group}/{entry.Row}", highlight)
|
||||||
|
|| selected == i;
|
||||||
|
|
||||||
|
if (i < 10)
|
||||||
|
{
|
||||||
|
var button = (i + 1) % 10;
|
||||||
|
var text = string.Format(Language.AutoTranslate_Completion_Key, button);
|
||||||
|
var size = ImGui.CalcTextSize(text);
|
||||||
|
ImGui.SameLine(ImGui.GetContentRegionAvail().X - size.X);
|
||||||
|
using (
|
||||||
|
ImRaii.PushColor(
|
||||||
|
ImGuiCol.Text,
|
||||||
|
ImGui.GetStyle().Colors[(int)ImGuiCol.TextDisabled]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ImGui.TextUnformatted(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clicked)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// StartPos/EndPos are CHAR offsets — see SlashCommandCallback's
|
||||||
|
// CallbackCompletion branch for the byte→char conversion rationale.
|
||||||
|
var start = _autoCompleteInfo.StartPos;
|
||||||
|
var end = _autoCompleteInfo.EndPos;
|
||||||
|
var replacement = $"<at:{entry.Group},{entry.Row}>";
|
||||||
|
_pendingMessage = _pendingMessage[..start] + replacement + _pendingMessage[end..];
|
||||||
|
ImGui.CloseCurrentPopup();
|
||||||
|
Activate = true;
|
||||||
|
_activatePos = start + replacement.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_autoCompleteShouldScroll)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_autoCompleteShouldScroll = false;
|
||||||
|
var selectedPos =
|
||||||
|
clipper.DisplayEnd > 0
|
||||||
|
? _autoCompleteSelection * ImGui.GetTextLineHeightWithSpacing()
|
||||||
|
: 0f;
|
||||||
|
ImGui.SetScrollY(selectedPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int AutoCompleteCallback(scoped ref ImGuiInputTextCallbackData data)
|
||||||
|
{
|
||||||
|
// Runs every frame because the search field sets CallbackAlways. First
|
||||||
|
// frame after IsWindowAppearing flips _fixCursor on so the caret lands
|
||||||
|
// at the end of the pre-filled prefix instead of position 0.
|
||||||
|
if (data.EventFlag == ImGuiInputTextFlags.CallbackAlways)
|
||||||
|
{
|
||||||
|
if (_fixCursor && _autoCompleteInfo != null)
|
||||||
|
{
|
||||||
|
data.CursorPos = _autoCompleteInfo.ToComplete.Length;
|
||||||
|
data.SelectionStart = data.SelectionEnd = data.CursorPos;
|
||||||
|
_fixCursor = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_autoCompleteList == null || _autoCompleteList.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
switch (data.EventKey)
|
||||||
|
{
|
||||||
|
case ImGuiKey.UpArrow:
|
||||||
|
_autoCompleteSelection =
|
||||||
|
_autoCompleteSelection == 0
|
||||||
|
? _autoCompleteList.Count - 1
|
||||||
|
: _autoCompleteSelection - 1;
|
||||||
|
_autoCompleteShouldScroll = true;
|
||||||
|
return 1;
|
||||||
|
case ImGuiKey.DownArrow:
|
||||||
|
_autoCompleteSelection =
|
||||||
|
_autoCompleteSelection == _autoCompleteList.Count - 1
|
||||||
|
? 0
|
||||||
|
: _autoCompleteSelection + 1;
|
||||||
|
_autoCompleteShouldScroll = true;
|
||||||
|
return 1;
|
||||||
|
default:
|
||||||
|
// Tab inside the popup cycles forward — CallbackHistory does
|
||||||
|
// not fire for Tab, so we sniff it via IsKeyPressed inside
|
||||||
|
// the CallbackAlways pass.
|
||||||
|
if (ImGui.IsKeyPressed(ImGuiKey.Tab))
|
||||||
|
{
|
||||||
|
_autoCompleteSelection = (_autoCompleteSelection + 1) % _autoCompleteList.Count;
|
||||||
|
_autoCompleteShouldScroll = true;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTO for an in-flight auto-translate completion. Lives as a companion type
|
||||||
|
// in this file because it is only consumed by InputBar (see v1.7.1 Fix #4 plan §2.4).
|
||||||
|
internal sealed class AutoCompleteInfo
|
||||||
|
{
|
||||||
|
// ToComplete MUST be a mutable field (not an auto-property), because the
|
||||||
|
// popup's ImGui.InputTextWithHint(... ref _autoCompleteInfo.ToComplete, ...)
|
||||||
|
// call takes it as a ref-parameter. Auto-properties cannot be passed as
|
||||||
|
// ref-targets — would produce CS0206 at compile time.
|
||||||
|
internal string ToComplete;
|
||||||
|
internal int StartPos { get; }
|
||||||
|
internal int EndPos { get; }
|
||||||
|
|
||||||
|
internal AutoCompleteInfo(string toComplete, int startPos, int endPos)
|
||||||
|
{
|
||||||
|
ToComplete = toComplete;
|
||||||
|
StartPos = startPos;
|
||||||
|
EndPos = endPos;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user