removed a ton of xml comments and such
This commit is contained in:
@@ -3,50 +3,39 @@ using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.EncounterSystem.Editor {
|
||||
/// <summary>
|
||||
/// Drawer for <see cref="DialogLineRef"/>. Shows the library + id picker on one row, the inline
|
||||
/// fallback text area underneath, and a resolved preview below that. Preserves WYSIWYG — the
|
||||
/// final text is always visible.
|
||||
/// </summary>
|
||||
/// <summary>Id dropdown (library inherited from the owning asset's <c>library</c> field) + inline fallback + resolved preview.</summary>
|
||||
[CustomPropertyDrawer(typeof(DialogLineRef))]
|
||||
public class DialogLineRefDrawer : PropertyDrawer {
|
||||
private const string NonePlaceholder = "<none>";
|
||||
private const string EmptyLibraryPlaceholder = "<assign a library first>";
|
||||
private const string EmptyLibraryPlaceholder = "<set library on the parent asset>";
|
||||
private const float PreviewHeight = 32f;
|
||||
|
||||
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
|
||||
var libraryProp = property.FindPropertyRelative("library");
|
||||
var idProp = property.FindPropertyRelative("id");
|
||||
var inlineProp = property.FindPropertyRelative("inlineText");
|
||||
var library = ResolveLibrary(property);
|
||||
|
||||
EditorGUI.BeginProperty(position, label, property);
|
||||
|
||||
var lineHeight = EditorGUIUtility.singleLineHeight;
|
||||
var spacing = EditorGUIUtility.standardVerticalSpacing;
|
||||
|
||||
var libraryRect = new Rect(position.x, position.y, position.width, lineHeight);
|
||||
EditorGUI.BeginChangeCheck();
|
||||
EditorGUI.PropertyField(libraryRect, libraryProp, label);
|
||||
var libraryChanged = EditorGUI.EndChangeCheck();
|
||||
var idRect = new Rect(position.x, position.y, position.width, lineHeight);
|
||||
DrawIdPicker(idRect, library, idProp, label);
|
||||
|
||||
using(new EditorGUI.IndentLevelScope()) {
|
||||
var idRect = new Rect(position.x, position.y + lineHeight + spacing, position.width, lineHeight);
|
||||
DrawIdPicker(idRect, libraryProp, idProp, libraryChanged);
|
||||
var inlineRect = new Rect(
|
||||
position.x,
|
||||
position.y + lineHeight + spacing,
|
||||
position.width,
|
||||
lineHeight * 2);
|
||||
EditorGUI.PropertyField(inlineRect, inlineProp, new GUIContent("Inline"));
|
||||
|
||||
var inlineRect = new Rect(
|
||||
position.x,
|
||||
position.y + (lineHeight + spacing) * 2,
|
||||
position.width,
|
||||
lineHeight * 2);
|
||||
EditorGUI.PropertyField(inlineRect, inlineProp, new GUIContent("Inline"));
|
||||
|
||||
var previewRect = new Rect(
|
||||
position.x,
|
||||
position.y + (lineHeight + spacing) * 2 + lineHeight * 2 + spacing,
|
||||
position.width,
|
||||
PreviewHeight);
|
||||
DrawPreview(previewRect, libraryProp, idProp, inlineProp);
|
||||
}
|
||||
var previewRect = new Rect(
|
||||
position.x,
|
||||
position.y + lineHeight + spacing + lineHeight * 2 + spacing,
|
||||
position.width,
|
||||
PreviewHeight);
|
||||
DrawPreview(previewRect, library, idProp, inlineProp);
|
||||
|
||||
EditorGUI.EndProperty();
|
||||
}
|
||||
@@ -54,18 +43,19 @@ namespace Jovian.EncounterSystem.Editor {
|
||||
public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
|
||||
var lineHeight = EditorGUIUtility.singleLineHeight;
|
||||
var spacing = EditorGUIUtility.standardVerticalSpacing;
|
||||
// library + id + (inline 2 lines) + preview
|
||||
return (lineHeight + spacing) * 2 + lineHeight * 2 + spacing + PreviewHeight;
|
||||
// id + (inline 2 lines) + preview
|
||||
return lineHeight + spacing + lineHeight * 2 + spacing + PreviewHeight;
|
||||
}
|
||||
|
||||
private static void DrawIdPicker(Rect rect, SerializedProperty libraryProp, SerializedProperty idProp, bool libraryChanged) {
|
||||
var library = libraryProp.objectReferenceValue as DialogLineLibrary;
|
||||
private static DialogLineLibrary ResolveLibrary(SerializedProperty property) {
|
||||
var libraryProp = property.serializedObject.FindProperty("library");
|
||||
return libraryProp?.objectReferenceValue as DialogLineLibrary;
|
||||
}
|
||||
|
||||
private static void DrawIdPicker(Rect rect, DialogLineLibrary library, SerializedProperty idProp, GUIContent label) {
|
||||
if(library == null || library.lines == null || library.lines.Count == 0) {
|
||||
using(new EditorGUI.DisabledScope(true)) {
|
||||
EditorGUI.Popup(rect, "Id", 0, new[] { EmptyLibraryPlaceholder });
|
||||
}
|
||||
if(libraryChanged) {
|
||||
idProp.stringValue = string.Empty;
|
||||
EditorGUI.Popup(rect, label.text, 0, new[] { EmptyLibraryPlaceholder });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -82,24 +72,18 @@ namespace Jovian.EncounterSystem.Editor {
|
||||
var id = line?.id ?? string.Empty;
|
||||
ids[i + 1] = id;
|
||||
names[i + 1] = string.IsNullOrEmpty(id) ? $"<unnamed {i}>" : id;
|
||||
if(!libraryChanged && id == idProp.stringValue && !string.IsNullOrEmpty(id)) {
|
||||
if(id == idProp.stringValue && !string.IsNullOrEmpty(id)) {
|
||||
currentIndex = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if(libraryChanged) {
|
||||
idProp.stringValue = string.Empty;
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
var newIndex = EditorGUI.Popup(rect, "Id", currentIndex, names);
|
||||
var newIndex = EditorGUI.Popup(rect, label.text, currentIndex, names);
|
||||
if(newIndex != currentIndex) {
|
||||
idProp.stringValue = ids[newIndex];
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawPreview(Rect rect, SerializedProperty libraryProp, SerializedProperty idProp, SerializedProperty inlineProp) {
|
||||
var library = libraryProp.objectReferenceValue as DialogLineLibrary;
|
||||
private static void DrawPreview(Rect rect, DialogLineLibrary library, SerializedProperty idProp, SerializedProperty inlineProp) {
|
||||
var id = idProp.stringValue;
|
||||
var inline = inlineProp.stringValue;
|
||||
|
||||
|
||||
@@ -8,11 +8,7 @@ using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace Jovian.EncounterSystem.Editor {
|
||||
/// <summary>
|
||||
/// Designer-facing browser for every <see cref="IEncounter"/> authored across all
|
||||
/// <see cref="EncounterTable"/> assets in the project. Virtualised ListView on the left,
|
||||
/// property-field detail pane on the right. Search by id/name/description; filter by kind.
|
||||
/// </summary>
|
||||
/// <summary>Browser for every encounter across all tables. Search + kind filter + detail pane. Quest chains render as a tree rooted at the first step.</summary>
|
||||
public class EncounterBrowserWindow : EditorWindow {
|
||||
private const string AllKinds = "All";
|
||||
|
||||
@@ -23,13 +19,12 @@ namespace Jovian.EncounterSystem.Editor {
|
||||
}
|
||||
|
||||
private readonly List<Record> allRecords = new();
|
||||
private List<Record> filteredRecords = new();
|
||||
private string searchText = string.Empty;
|
||||
private string kindFilter = AllKinds;
|
||||
|
||||
private readonly Dictionary<IEncounter, List<ValidationIssue>> issuesByEncounter = new();
|
||||
|
||||
private ListView listView;
|
||||
private TreeView treeView;
|
||||
private VisualElement detailPane;
|
||||
private ToolbarMenu kindDropdown;
|
||||
|
||||
@@ -78,15 +73,15 @@ namespace Jovian.EncounterSystem.Editor {
|
||||
split.style.flexGrow = 1f;
|
||||
rootVisualElement.Add(split);
|
||||
|
||||
listView = new ListView {
|
||||
treeView = new TreeView {
|
||||
makeItem = MakeRow,
|
||||
bindItem = BindRow,
|
||||
fixedItemHeight = 22,
|
||||
selectionType = SelectionType.Single
|
||||
};
|
||||
listView.selectionChanged += OnSelectionChanged;
|
||||
listView.style.flexGrow = 1f;
|
||||
split.Add(listView);
|
||||
treeView.selectionChanged += OnSelectionChanged;
|
||||
treeView.style.flexGrow = 1f;
|
||||
split.Add(treeView);
|
||||
|
||||
detailPane = new ScrollView(ScrollViewMode.Vertical) {
|
||||
style = { paddingLeft = 8, paddingTop = 8, paddingRight = 8, flexGrow = 1f }
|
||||
@@ -134,7 +129,7 @@ namespace Jovian.EncounterSystem.Editor {
|
||||
}
|
||||
|
||||
private void BindRow(VisualElement element, int index) {
|
||||
var record = filteredRecords[index];
|
||||
var record = treeView.GetItemDataForIndex<Record>(index);
|
||||
var label = element.Q<Label>("row-label");
|
||||
var badge = element.Q<VisualElement>("issue-badge");
|
||||
|
||||
@@ -222,15 +217,73 @@ namespace Jovian.EncounterSystem.Editor {
|
||||
}
|
||||
|
||||
private void ApplyFilter() {
|
||||
filteredRecords = allRecords.Where(Matches).ToList();
|
||||
if(listView != null) {
|
||||
listView.itemsSource = filteredRecords;
|
||||
listView.Rebuild();
|
||||
listView.ClearSelection();
|
||||
var filtered = allRecords.Where(Matches).ToList();
|
||||
var items = BuildTreeItems(filtered);
|
||||
if(treeView != null) {
|
||||
treeView.SetRootItems(items);
|
||||
treeView.Rebuild();
|
||||
treeView.ClearSelection();
|
||||
treeView.ExpandAll();
|
||||
}
|
||||
ShowEmptyDetail();
|
||||
}
|
||||
|
||||
private static List<TreeViewItemData<Record>> BuildTreeItems(List<Record> records) {
|
||||
var byEncounter = new Dictionary<IEncounter, Record>();
|
||||
foreach(var r in records) {
|
||||
if(r.encounter != null) {
|
||||
byEncounter[r.encounter] = r;
|
||||
}
|
||||
}
|
||||
|
||||
// Any encounter that is a `nextEncounter` target of another record in the filtered set
|
||||
// becomes a non-root (rendered as a child), not a top-level item.
|
||||
var nonRoot = new HashSet<IEncounter>();
|
||||
foreach(var r in records) {
|
||||
if(r.encounter?.Kind is not QuestKind quest) {
|
||||
continue;
|
||||
}
|
||||
var next = quest.nextEncounter.Resolve();
|
||||
if(next != null && byEncounter.ContainsKey(next)) {
|
||||
nonRoot.Add(next);
|
||||
}
|
||||
}
|
||||
|
||||
var result = new List<TreeViewItemData<Record>>();
|
||||
var uid = 0;
|
||||
foreach(var r in records) {
|
||||
if(r.encounter != null && nonRoot.Contains(r.encounter)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var children = BuildChainChildren(r, byEncounter, ref uid);
|
||||
result.Add(new TreeViewItemData<Record>(uid++, r, children));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<TreeViewItemData<Record>> BuildChainChildren(Record root, Dictionary<IEncounter, Record> byEncounter, ref int uid) {
|
||||
var children = new List<TreeViewItemData<Record>>();
|
||||
if(root.encounter?.Kind is not QuestKind) {
|
||||
return children;
|
||||
}
|
||||
|
||||
var current = root.encounter;
|
||||
var visited = new HashSet<IEncounter> { current };
|
||||
while(current?.Kind is QuestKind quest) {
|
||||
var next = quest.nextEncounter.Resolve();
|
||||
if(next == null || !visited.Add(next)) {
|
||||
break;
|
||||
}
|
||||
if(!byEncounter.TryGetValue(next, out var nextRecord)) {
|
||||
break;
|
||||
}
|
||||
children.Add(new TreeViewItemData<Record>(uid++, nextRecord));
|
||||
current = next;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
private bool Matches(Record record) {
|
||||
var kindName = record.encounter?.Kind?.GetType().Name ?? string.Empty;
|
||||
if(kindFilter != AllKinds && kindName != kindFilter) {
|
||||
|
||||
76
Editor/EncounterDialogOptionDrawer.cs
Normal file
76
Editor/EncounterDialogOptionDrawer.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using Jovian.EncounterSystem;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.EncounterSystem.Editor {
|
||||
/// <summary>Label list elements with the option's text id (fallback: inline text preview, then default).</summary>
|
||||
[CustomPropertyDrawer(typeof(EncounterDialogOption))]
|
||||
public class EncounterDialogOptionDrawer : PropertyDrawer {
|
||||
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
|
||||
var displayLabel = ResolveLabel(property, label);
|
||||
|
||||
var foldoutRect = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight);
|
||||
property.isExpanded = EditorGUI.Foldout(foldoutRect, property.isExpanded, displayLabel, true);
|
||||
|
||||
if(!property.isExpanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
EditorGUI.indentLevel++;
|
||||
var y = position.y + EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
|
||||
var iterator = property.Copy();
|
||||
var end = iterator.GetEndProperty();
|
||||
if(iterator.NextVisible(true)) {
|
||||
while(!SerializedProperty.EqualContents(iterator, end)) {
|
||||
var h = EditorGUI.GetPropertyHeight(iterator, true);
|
||||
var r = new Rect(position.x, y, position.width, h);
|
||||
EditorGUI.PropertyField(r, iterator, true);
|
||||
y += h + EditorGUIUtility.standardVerticalSpacing;
|
||||
if(!iterator.NextVisible(false)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
|
||||
public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
|
||||
var height = EditorGUIUtility.singleLineHeight;
|
||||
if(!property.isExpanded) {
|
||||
return height;
|
||||
}
|
||||
|
||||
var iterator = property.Copy();
|
||||
var end = iterator.GetEndProperty();
|
||||
if(iterator.NextVisible(true)) {
|
||||
while(!SerializedProperty.EqualContents(iterator, end)) {
|
||||
height += EditorGUI.GetPropertyHeight(iterator, true) + EditorGUIUtility.standardVerticalSpacing;
|
||||
if(!iterator.NextVisible(false)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
private static GUIContent ResolveLabel(SerializedProperty property, GUIContent fallback) {
|
||||
var textProp = property.FindPropertyRelative("text");
|
||||
if(textProp == null) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var id = textProp.FindPropertyRelative("id")?.stringValue;
|
||||
if(!string.IsNullOrEmpty(id)) {
|
||||
return new GUIContent(id);
|
||||
}
|
||||
|
||||
var inline = textProp.FindPropertyRelative("inlineText")?.stringValue;
|
||||
if(!string.IsNullOrEmpty(inline)) {
|
||||
var preview = inline.Length > 40 ? inline.Substring(0, 40) + "…" : inline;
|
||||
return new GUIContent(preview);
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,7 @@ using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.EncounterSystem.Editor {
|
||||
/// <summary>
|
||||
/// Draws each <see cref="Encounter"/> list element with its <see cref="EncounterDefinition.id"/>
|
||||
/// as the foldout label (falling back to <c>name</c>, then the default element label).
|
||||
/// Children are iterated manually to avoid recursing into this drawer.
|
||||
/// </summary>
|
||||
/// <summary>Label list elements with the encounter id (fallback: name, then default).</summary>
|
||||
[CustomPropertyDrawer(typeof(Encounter))]
|
||||
public class EncounterDrawer : PropertyDrawer {
|
||||
private const string DefinitionBackingField = "<EncounterDefinition>k__BackingField";
|
||||
|
||||
@@ -3,11 +3,7 @@ using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.EncounterSystem.Editor {
|
||||
/// <summary>
|
||||
/// Two-row drawer for <see cref="EncounterLink"/>. Row 1 is an asset object-field for the target
|
||||
/// <see cref="EncounterTable"/>; row 2 is a dropdown of encounters inside that table labelled by
|
||||
/// <c>EncounterDefinition.name</c>. Picking a different table clears the stored internalId.
|
||||
/// </summary>
|
||||
/// <summary>Table object-field + encounter dropdown picker. Changing tables clears the id.</summary>
|
||||
[CustomPropertyDrawer(typeof(EncounterLink))]
|
||||
public class EncounterLinkDrawer : PropertyDrawer {
|
||||
private const string NonePlaceholder = "<none>";
|
||||
|
||||
@@ -4,18 +4,11 @@ using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.EncounterSystem.Editor {
|
||||
/// <summary>Severity of a <see cref="ValidationIssue"/>.</summary>
|
||||
public enum ValidationSeverity {
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One thing the validator found wrong. <see cref="asset"/> is the click-through target
|
||||
/// (table, dialog option set, reward), <see cref="encounter"/> identifies the specific
|
||||
/// encounter the issue pertains to when applicable, and <see cref="path"/> describes where
|
||||
/// inside the asset the issue lives.
|
||||
/// </summary>
|
||||
public class ValidationIssue {
|
||||
public Object asset;
|
||||
public Encounter encounter;
|
||||
@@ -24,13 +17,8 @@ namespace Jovian.EncounterSystem.Editor {
|
||||
public string message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans every <see cref="EncounterTable"/> and <see cref="Reward"/> asset in the project and
|
||||
/// returns the list of issues found. Intended to be driven from a menu item, the browser window,
|
||||
/// or pre-commit tooling. Runs on demand — does not cache.
|
||||
/// </summary>
|
||||
/// <summary>Project-wide scan of encounter tables and rewards. Runs on demand, no caching.</summary>
|
||||
public static class EncounterValidator {
|
||||
/// <summary>Walk every encounter table and reward asset, returning all issues found.</summary>
|
||||
public static List<ValidationIssue> ValidateProject() {
|
||||
var issues = new List<ValidationIssue>();
|
||||
|
||||
@@ -48,7 +36,6 @@ namespace Jovian.EncounterSystem.Editor {
|
||||
return issues;
|
||||
}
|
||||
|
||||
/// <summary>Return the issues for a single encounter inside a table. Useful for per-row badges.</summary>
|
||||
public static List<ValidationIssue> ValidateEncounter(EncounterTable table, int index) {
|
||||
var issues = new List<ValidationIssue>();
|
||||
if(table?.encounters == null || index < 0 || index >= table.encounters.Count) {
|
||||
@@ -197,38 +184,39 @@ namespace Jovian.EncounterSystem.Editor {
|
||||
}
|
||||
|
||||
private static void ValidateDialogLineRef(EncounterDialogOptionSet optionSet, Encounter encounter, string path, DialogLineRef lineRef, List<ValidationIssue> issues) {
|
||||
var hasLibrary = lineRef.library != null;
|
||||
var library = optionSet.library;
|
||||
var hasId = !string.IsNullOrEmpty(lineRef.id);
|
||||
var hasInline = !string.IsNullOrEmpty(lineRef.inlineText);
|
||||
|
||||
if(!hasLibrary && !hasInline) {
|
||||
if(!hasId && !hasInline) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = optionSet,
|
||||
encounter = encounter,
|
||||
path = path,
|
||||
severity = ValidationSeverity.Warning,
|
||||
message = "Dialog line is empty (no library reference and no inline text)."
|
||||
message = "Dialog line is empty (no id and no inline text)."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if(hasLibrary && hasId && string.IsNullOrEmpty(lineRef.library.Resolve(lineRef.id))) {
|
||||
if(hasId && library == null) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = optionSet,
|
||||
encounter = encounter,
|
||||
path = path,
|
||||
severity = ValidationSeverity.Error,
|
||||
message = $"DialogLineRef id '{lineRef.id}' not found in library '{lineRef.library.name}'."
|
||||
message = $"DialogLineRef references id '{lineRef.id}' but the option set has no library assigned."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if(hasLibrary && !hasId && !hasInline) {
|
||||
if(hasId && library != null && string.IsNullOrEmpty(library.Resolve(lineRef.id))) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = optionSet,
|
||||
encounter = encounter,
|
||||
path = path,
|
||||
severity = ValidationSeverity.Warning,
|
||||
message = "Library assigned but no id picked; will fall back to empty inline text."
|
||||
severity = ValidationSeverity.Error,
|
||||
message = $"DialogLineRef id '{lineRef.id}' not found in library '{library.name}'."
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -271,7 +259,6 @@ namespace Jovian.EncounterSystem.Editor {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Menu-driven runner that prints the validator report to the console with click-through asset pings.</summary>
|
||||
public static class EncounterValidatorMenu {
|
||||
[MenuItem("Jovian/Encounters/Validate All")]
|
||||
public static void ValidateAll() {
|
||||
|
||||
@@ -6,16 +6,7 @@ using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.EncounterSystem.Editor {
|
||||
/// <summary>
|
||||
/// Custom drawer for any field marked <c>[SerializeReference, SubclassSelector]</c>. Renders a
|
||||
/// "pick a concrete type" dropdown populated by reflection over the declared base type, then
|
||||
/// draws the chosen instance's serialized children underneath. Works for single fields, arrays,
|
||||
/// and <see cref="List{T}"/> elements.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The type cache is a <c>static readonly</c> dictionary; it is implicitly cleared on domain
|
||||
/// reload because the static field is recreated with the new assembly image.
|
||||
/// </remarks>
|
||||
/// <summary>Concrete-type dropdown for <c>[SerializeReference]</c> fields, arrays, and list elements.</summary>
|
||||
[CustomPropertyDrawer(typeof(SubclassSelectorAttribute))]
|
||||
public class SubclassSelectorDrawer : PropertyDrawer {
|
||||
private static readonly Dictionary<Type, Type[]> TypeCache = new();
|
||||
|
||||
Reference in New Issue
Block a user