WIP Abstraction
This commit is contained in:
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Linq;
|
||||
using AetherBags.Configuration;
|
||||
using AetherBags.Inventory;
|
||||
using AetherBags.Inventory.Context;
|
||||
using Dalamud.Game.Addon.Lifecycle;
|
||||
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
|
||||
using Dalamud.Game.NativeWrapper;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AetherBags.Inventory;
|
||||
using AetherBags.Inventory.Context;
|
||||
using AetherBags.Inventory.State;
|
||||
using AetherBags.Nodes.Input;
|
||||
using AetherBags.Nodes.Inventory;
|
||||
using AetherBags.Nodes.Layout;
|
||||
@@ -11,43 +9,20 @@ using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
|
||||
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||
using KamiToolKit;
|
||||
using KamiToolKit.Nodes;
|
||||
|
||||
namespace AetherBags.Addons;
|
||||
|
||||
public class AddonInventoryWindow : NativeAddon
|
||||
public unsafe class AddonInventoryWindow : InventoryAddonBase
|
||||
{
|
||||
private readonly InventoryCategoryHoverCoordinator _hoverCoordinator = new();
|
||||
private readonly InventoryCategoryPinCoordinator _pinCoordinator = new();
|
||||
private readonly HashSet<InventoryCategoryNode> _hoverSubscribed = new();
|
||||
|
||||
private readonly MainBagState _inventoryState = new();
|
||||
private InventoryNotificationNode _notificationNode = null!;
|
||||
private WrappingGridNode<InventoryCategoryNode> _categoriesNode = null!;
|
||||
private TextInputWithHintNode _searchInputNode = null!;
|
||||
private CircleButtonNode _settingsButtonNode = null!;
|
||||
private InventoryFooterNode _footerNode = null!;
|
||||
|
||||
// Window constraints
|
||||
private const float MinWindowWidth = 300;
|
||||
private const float MaxWindowWidth = 800;
|
||||
private const float MinWindowHeight = 200;
|
||||
private const float MaxWindowHeight = 1000;
|
||||
protected override InventoryStateBase InventoryState => _inventoryState;
|
||||
|
||||
// Layout settings
|
||||
private const float CategorySpacing = 12;
|
||||
private const float ItemSize = 40;
|
||||
private const float ItemPadding = 4;
|
||||
|
||||
private const float FooterHeight = 28f;
|
||||
private const float FooterTopSpacing = 4f;
|
||||
|
||||
private bool _refreshQueued;
|
||||
private bool _refreshAutosizeQueued;
|
||||
|
||||
protected override unsafe void OnSetup(AtkUnitBase* addon)
|
||||
protected override void OnSetup(AtkUnitBase* addon)
|
||||
{
|
||||
_categoriesNode = new WrappingGridNode<InventoryCategoryNode>
|
||||
CategoriesNode = new WrappingGridNode<InventoryCategoryNode>
|
||||
{
|
||||
Position = ContentStartPosition,
|
||||
Size = ContentSize,
|
||||
@@ -56,7 +31,7 @@ public class AddonInventoryWindow : NativeAddon
|
||||
TopPadding = 4.0f,
|
||||
BottomPadding = 4.0f,
|
||||
};
|
||||
_categoriesNode.AttachNode(this);
|
||||
CategoriesNode.AttachNode(this);
|
||||
|
||||
var size = new Vector2(addon->Size.X / 2.0f, 28.0f);
|
||||
|
||||
@@ -77,49 +52,48 @@ public class AddonInventoryWindow : NativeAddon
|
||||
};
|
||||
_notificationNode.AttachNode(this);
|
||||
|
||||
_searchInputNode = new TextInputWithHintNode
|
||||
SearchInputNode = new TextInputWithHintNode
|
||||
{
|
||||
Position = new Vector2(x, y),
|
||||
Size = size,
|
||||
OnInputReceived = _ => RefreshCategoriesCore(autosize: false),
|
||||
};
|
||||
_searchInputNode.AttachNode(this);
|
||||
SearchInputNode.AttachNode(this);
|
||||
|
||||
_settingsButtonNode = new CircleButtonNode
|
||||
SettingsButtonNode = new CircleButtonNode
|
||||
{
|
||||
Position = new Vector2(headerW - 48f, y),
|
||||
Size = new Vector2(28f),
|
||||
Icon = ButtonIcon.GearCog,
|
||||
OnClick = System.AddonConfigurationWindow.Toggle
|
||||
};
|
||||
_settingsButtonNode.AttachNode(this);
|
||||
SettingsButtonNode.AttachNode(this);
|
||||
|
||||
_footerNode = new InventoryFooterNode
|
||||
FooterNode = new InventoryFooterNode
|
||||
{
|
||||
Size = ContentSize with { Y = FooterHeight },
|
||||
SlotAmountText = InventoryState.GetEmptyItemSlotsString(),
|
||||
SlotAmountText = _inventoryState.GetEmptySlotsString(),
|
||||
};
|
||||
_footerNode.AttachNode(this);
|
||||
FooterNode.AttachNode(this);
|
||||
|
||||
LayoutContent();
|
||||
|
||||
Services.AddonLifecycle.RegisterListener(AddonEvent.PostRequestedUpdate, "Inventory", OnInventoryUpdate);
|
||||
addon->SubscribeAtkArrayData(1, (int)NumberArrayType.Inventory);
|
||||
|
||||
InventoryState.RefreshFromGame();
|
||||
|
||||
RefreshCategoriesCore(autosize: true);
|
||||
_inventoryState.RefreshFromGame();
|
||||
RefreshCategoriesCore(autosize: true);
|
||||
|
||||
base.OnSetup(addon);
|
||||
}
|
||||
|
||||
protected override unsafe void OnUpdate(AtkUnitBase* addon)
|
||||
protected override void OnUpdate(AtkUnitBase* addon)
|
||||
{
|
||||
if (_refreshQueued)
|
||||
if (RefreshQueued)
|
||||
{
|
||||
bool doAutosize = _refreshAutosizeQueued;
|
||||
_refreshQueued = false;
|
||||
_refreshAutosizeQueued = false;
|
||||
bool doAutosize = RefreshAutosizeQueued;
|
||||
RefreshQueued = false;
|
||||
RefreshAutosizeQueued = false;
|
||||
|
||||
RefreshCategoriesCore(doAutosize);
|
||||
}
|
||||
@@ -127,191 +101,31 @@ public class AddonInventoryWindow : NativeAddon
|
||||
base.OnUpdate(addon);
|
||||
}
|
||||
|
||||
public void ManualInventoryRefresh()
|
||||
{
|
||||
if (!Services.ClientState.IsLoggedIn) return;
|
||||
InventoryState.RefreshFromGame();
|
||||
RefreshCategoriesCore(true);
|
||||
}
|
||||
|
||||
/*public void UpdateLootedCategory(IReadOnlyList<LootedItemInfo> lootedItemInfos)
|
||||
{
|
||||
if (!Services.ClientState.IsLoggedIn) return;
|
||||
_recentlyLootedCategoryNode?.CategorizedInventory.Items.AddRange(
|
||||
lootedItemInfos.Select(x => new ItemInfo
|
||||
{
|
||||
ItemCount = x.Quantity,
|
||||
Key = uint.MaxValue - 1,
|
||||
Item = x.Item,
|
||||
})
|
||||
.ToList());
|
||||
RefreshCategoriesCore(true);
|
||||
}*/
|
||||
|
||||
public void ManualCurrencyRefresh()
|
||||
{
|
||||
if (!Services.ClientState.IsLoggedIn) return;
|
||||
_footerNode.RefreshCurrencies();
|
||||
FooterNode.RefreshCurrencies();
|
||||
}
|
||||
|
||||
private void OnInventoryUpdate(AddonEvent type, AddonArgs args)
|
||||
{
|
||||
InventoryState.RefreshFromGame();
|
||||
|
||||
_inventoryState.RefreshFromGame();
|
||||
RefreshCategoriesCore(autosize: true);
|
||||
}
|
||||
|
||||
protected override unsafe void OnRequestedUpdate(AtkUnitBase* addon, NumberArrayData** numberArrayData, StringArrayData** stringArrayData)
|
||||
protected override void OnRequestedUpdate(AtkUnitBase* addon, NumberArrayData** numberArrayData, StringArrayData** stringArrayData)
|
||||
{
|
||||
base.OnRequestedUpdate(addon, numberArrayData, stringArrayData);
|
||||
|
||||
InventoryState.RefreshFromGame();
|
||||
|
||||
_inventoryState.RefreshFromGame();
|
||||
RefreshCategoriesCore(autosize: true);
|
||||
}
|
||||
|
||||
private void RefreshCategoriesCore(bool autosize)
|
||||
{
|
||||
_footerNode.SlotAmountText = InventoryState.GetEmptyItemSlotsString();
|
||||
_footerNode.RefreshCurrencies();
|
||||
|
||||
string filter = _searchInputNode.SearchString.ExtractText();
|
||||
IReadOnlyList<CategorizedInventory> categories = InventoryState.GetInventoryItemCategories(filter);
|
||||
|
||||
float maxContentWidth = MaxWindowWidth - (ContentStartPosition.X * 2);
|
||||
int maxItemsPerLine = CalculateOptimalItemsPerLine(maxContentWidth);
|
||||
|
||||
_categoriesNode.SyncWithListDataByKey<CategorizedInventory, InventoryCategoryNode, uint>(
|
||||
dataList: categories,
|
||||
getKeyFromData: c => c.Key,
|
||||
getKeyFromNode: n => n.CategorizedInventory.Key,
|
||||
updateNode: (node, data) =>
|
||||
{
|
||||
node.CategorizedInventory = data;
|
||||
node.ItemsPerLine = Math.Min(data.Items.Count, maxItemsPerLine);
|
||||
},
|
||||
createNodeMethod: _ => new InventoryCategoryNode
|
||||
{
|
||||
Size = ContentSize with { Y = 120 },
|
||||
});
|
||||
|
||||
bool pinsChanged = _pinCoordinator.ApplyPinnedStates(_categoriesNode);
|
||||
if (pinsChanged)
|
||||
_hoverCoordinator.ResetAll(_categoriesNode);
|
||||
|
||||
WireHoverHandlers();
|
||||
|
||||
if (autosize) AutoSizeWindow();
|
||||
else
|
||||
{
|
||||
LayoutContent();
|
||||
_categoriesNode.RecalculateLayout();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void WireHoverHandlers()
|
||||
{
|
||||
var nodes = _categoriesNode.Nodes;
|
||||
|
||||
for (int i = 0; i < nodes.Count; i++)
|
||||
{
|
||||
if (nodes[i] is not InventoryCategoryNode node)
|
||||
continue;
|
||||
|
||||
if (!_hoverSubscribed.Add(node))
|
||||
continue;
|
||||
|
||||
node.HeaderHoverChanged += (src, hovering) =>
|
||||
{
|
||||
_hoverCoordinator.OnCategoryHoverChanged(_categoriesNode, src, hovering);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private int CalculateOptimalItemsPerLine(float availableWidth)
|
||||
{
|
||||
return Math.Clamp((int)MathF.Floor((availableWidth + ItemPadding) / (ItemSize + ItemPadding)), 1, 15);
|
||||
}
|
||||
|
||||
private void LayoutContent()
|
||||
{
|
||||
Vector2 contentPos = ContentStartPosition;
|
||||
Vector2 contentSize = ContentSize;
|
||||
|
||||
float footerH = FooterHeight;
|
||||
|
||||
_footerNode.Position = new Vector2(contentPos.X, contentPos.Y + contentSize.Y - footerH);
|
||||
_footerNode.Size = new Vector2(contentSize.X, footerH);
|
||||
|
||||
float gridH = contentSize.Y - footerH - FooterTopSpacing;
|
||||
if (gridH < 0) gridH = 0;
|
||||
|
||||
_categoriesNode.Position = contentPos;
|
||||
_categoriesNode.Size = new Vector2(contentSize.X, gridH);
|
||||
}
|
||||
|
||||
private void AutoSizeWindow()
|
||||
{
|
||||
var nodes = _categoriesNode.Nodes;
|
||||
|
||||
float maxChildWidth = 0f;
|
||||
int childCount = 0;
|
||||
|
||||
for (int i = 0; i < nodes.Count; i++)
|
||||
{
|
||||
if (nodes[i] is not InventoryCategoryNode cat)
|
||||
continue;
|
||||
|
||||
childCount++;
|
||||
float w = cat.Width;
|
||||
if (w > maxChildWidth) maxChildWidth = w;
|
||||
}
|
||||
|
||||
if (childCount == 0)
|
||||
{
|
||||
ResizeWindow(MinWindowWidth, MinWindowHeight, recalcLayout: true);
|
||||
return;
|
||||
}
|
||||
|
||||
float requiredWidth = maxChildWidth + (ContentStartPosition.X * 2);
|
||||
float finalWidth = Math.Clamp(requiredWidth, MinWindowWidth, MaxWindowWidth);
|
||||
|
||||
float contentWidth = finalWidth - (ContentStartPosition.X * 2);
|
||||
|
||||
float gridBudget = Math.Max(0f, MaxWindowHeight - FooterHeight - FooterTopSpacing);
|
||||
|
||||
_categoriesNode.Position = ContentStartPosition;
|
||||
_categoriesNode.Size = new Vector2(contentWidth, gridBudget);
|
||||
|
||||
_categoriesNode.RecalculateLayout();
|
||||
|
||||
float requiredGridHeight = _categoriesNode.GetRequiredHeight();
|
||||
float requiredContentHeight = requiredGridHeight + FooterTopSpacing + FooterHeight;
|
||||
|
||||
float requiredWindowHeight = requiredContentHeight + ContentStartPosition.Y + ContentStartPosition.X;
|
||||
float finalHeight = Math.Clamp(requiredWindowHeight, MinWindowHeight, MaxWindowHeight);
|
||||
|
||||
ResizeWindow(finalWidth, finalHeight, recalcLayout: false);
|
||||
}
|
||||
|
||||
private void ResizeWindow(float width, float height, bool recalcLayout)
|
||||
{
|
||||
SetWindowSize(width, height);
|
||||
LayoutContent();
|
||||
|
||||
if (recalcLayout)
|
||||
_categoriesNode.RecalculateLayout();
|
||||
}
|
||||
|
||||
private void ResizeWindow(float width, float height)
|
||||
=> ResizeWindow(width, height, recalcLayout: true);
|
||||
|
||||
public void SetNotification(InventoryNotificationInfo info)
|
||||
{
|
||||
Services.Framework.RunOnTick(() =>
|
||||
{
|
||||
if(IsOpen) _notificationNode.NotificationInfo = info;
|
||||
if (IsOpen) _notificationNode.NotificationInfo = info;
|
||||
}, delayTicks: 1);
|
||||
}
|
||||
|
||||
@@ -319,13 +133,12 @@ public class AddonInventoryWindow : NativeAddon
|
||||
{
|
||||
Services.Framework.RunOnTick(() =>
|
||||
{
|
||||
if(IsOpen) _searchInputNode.SearchString = searchText;
|
||||
if (IsOpen) SearchInputNode.SearchString = searchText;
|
||||
RefreshCategoriesCore(autosize: true);
|
||||
}, delayTicks: 1);
|
||||
}
|
||||
|
||||
|
||||
protected override unsafe void OnFinalize(AtkUnitBase* addon)
|
||||
protected override void OnFinalize(AtkUnitBase* addon)
|
||||
{
|
||||
ref var blockingAddonId = ref AgentInventoryContext.Instance()->BlockingAddonId;
|
||||
if (blockingAddonId != 0)
|
||||
@@ -336,10 +149,6 @@ public class AddonInventoryWindow : NativeAddon
|
||||
Services.AddonLifecycle.UnregisterListener(OnInventoryUpdate);
|
||||
addon->UnsubscribeAtkArrayData(1, (int)NumberArrayType.Inventory);
|
||||
|
||||
_hoverSubscribed.Clear();
|
||||
_refreshQueued = false;
|
||||
_refreshAutosizeQueued = false;
|
||||
|
||||
base.OnFinalize(addon);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using AetherBags.Configuration;
|
||||
using AetherBags.Inventory;
|
||||
using AetherBags.Inventory.Categories;
|
||||
using KamiToolKit.Premade;
|
||||
|
||||
namespace AetherBags.Addons;
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AetherBags;
|
||||
using AetherBags.Inventory;
|
||||
using AetherBags.Inventory.Categories;
|
||||
using AetherBags.Inventory.State;
|
||||
using AetherBags.Nodes.Input;
|
||||
using AetherBags.Nodes.Inventory;
|
||||
using AetherBags.Nodes.Layout;
|
||||
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||
using KamiToolKit;
|
||||
using KamiToolKit.Nodes;
|
||||
|
||||
public abstract unsafe class InventoryAddonBase : NativeAddon
|
||||
{
|
||||
protected readonly InventoryCategoryHoverCoordinator HoverCoordinator = new();
|
||||
protected readonly InventoryCategoryPinCoordinator PinCoordinator = new();
|
||||
protected readonly HashSet<InventoryCategoryNode> HoverSubscribed = new();
|
||||
|
||||
protected WrappingGridNode<InventoryCategoryNode> CategoriesNode = null!;
|
||||
protected TextInputWithHintNode SearchInputNode = null!;
|
||||
protected InventoryFooterNode FooterNode = null!;
|
||||
protected CircleButtonNode SettingsButtonNode = null!;
|
||||
|
||||
protected virtual float MinWindowWidth => 600;
|
||||
protected virtual float MaxWindowWidth => 800;
|
||||
protected virtual float MinWindowHeight => 200;
|
||||
protected virtual float MaxWindowHeight => 1000;
|
||||
|
||||
protected const float CategorySpacing = 12;
|
||||
protected const float ItemSize = 40;
|
||||
protected const float ItemPadding = 4;
|
||||
protected const float FooterHeight = 28f;
|
||||
protected const float FooterTopSpacing = 4f;
|
||||
|
||||
protected bool RefreshQueued;
|
||||
protected bool RefreshAutosizeQueued;
|
||||
|
||||
protected abstract InventoryStateBase InventoryState { get; }
|
||||
|
||||
protected virtual bool HasFooter => true;
|
||||
protected virtual bool HasPinning => true;
|
||||
|
||||
public void ManualInventoryRefresh()
|
||||
{
|
||||
if (!Services.ClientState.IsLoggedIn) return;
|
||||
InventoryState.RefreshFromGame();
|
||||
RefreshCategoriesCore(autosize: true);
|
||||
}
|
||||
|
||||
protected virtual void RefreshCategoriesCore(bool autosize)
|
||||
{
|
||||
if (HasFooter)
|
||||
{
|
||||
FooterNode.SlotAmountText = InventoryState.GetEmptySlotsString();
|
||||
FooterNode.RefreshCurrencies();
|
||||
}
|
||||
|
||||
string filter = SearchInputNode.SearchString.ExtractText();
|
||||
var categories = InventoryState.GetCategories(filter);
|
||||
|
||||
float maxContentWidth = MaxWindowWidth - (ContentStartPosition.X * 2);
|
||||
int maxItemsPerLine = CalculateOptimalItemsPerLine(maxContentWidth);
|
||||
|
||||
CategoriesNode.SyncWithListDataByKey<CategorizedInventory, InventoryCategoryNode, uint>(
|
||||
dataList: categories,
|
||||
getKeyFromData: categorizedInventory => categorizedInventory.Key,
|
||||
getKeyFromNode: node => node.CategorizedInventory.Key,
|
||||
updateNode: (node, data) =>
|
||||
{
|
||||
node.CategorizedInventory = data;
|
||||
node.ItemsPerLine = Math.Min(data.Items.Count, maxItemsPerLine);
|
||||
},
|
||||
createNodeMethod: _ => CreateCategoryNode());
|
||||
|
||||
if (HasPinning)
|
||||
{
|
||||
bool pinsChanged = PinCoordinator.ApplyPinnedStates(CategoriesNode);
|
||||
if (pinsChanged)
|
||||
HoverCoordinator.ResetAll(CategoriesNode);
|
||||
}
|
||||
|
||||
WireHoverHandlers();
|
||||
|
||||
if (autosize)
|
||||
AutoSizeWindow();
|
||||
else
|
||||
{
|
||||
LayoutContent();
|
||||
CategoriesNode.RecalculateLayout();
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual InventoryCategoryNode CreateCategoryNode()
|
||||
{
|
||||
return new InventoryCategoryNode
|
||||
{
|
||||
Size = ContentSize with { Y = 120 },
|
||||
};
|
||||
}
|
||||
|
||||
protected void WireHoverHandlers()
|
||||
{
|
||||
var nodes = CategoriesNode.Nodes;
|
||||
|
||||
for (int i = 0; i < nodes.Count; i++)
|
||||
{
|
||||
if (nodes[i] is not InventoryCategoryNode node)
|
||||
continue;
|
||||
|
||||
if (!HoverSubscribed.Add(node))
|
||||
continue;
|
||||
|
||||
node.HeaderHoverChanged += (src, hovering) =>
|
||||
{
|
||||
HoverCoordinator.OnCategoryHoverChanged(CategoriesNode, src, hovering);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected int CalculateOptimalItemsPerLine(float availableWidth)
|
||||
=> Math.Clamp((int)MathF.Floor((availableWidth + ItemPadding) / (ItemSize + ItemPadding)), 1, 15);
|
||||
|
||||
protected virtual void LayoutContent()
|
||||
{
|
||||
Vector2 contentPos = ContentStartPosition;
|
||||
Vector2 contentSize = ContentSize;
|
||||
|
||||
if (HasFooter)
|
||||
{
|
||||
FooterNode.Position = new Vector2(contentPos.X, contentPos.Y + contentSize.Y - FooterHeight);
|
||||
FooterNode.Size = new Vector2(contentSize.X, FooterHeight);
|
||||
}
|
||||
|
||||
float gridH = contentSize.Y - (HasFooter ? FooterHeight + FooterTopSpacing : 0);
|
||||
if (gridH < 0) gridH = 0;
|
||||
|
||||
CategoriesNode.Position = contentPos;
|
||||
CategoriesNode.Size = new Vector2(contentSize.X, gridH);
|
||||
}
|
||||
|
||||
protected virtual void AutoSizeWindow()
|
||||
{
|
||||
var nodes = CategoriesNode.Nodes;
|
||||
|
||||
float maxChildWidth = 0f;
|
||||
int childCount = 0;
|
||||
|
||||
for (int i = 0; i < nodes.Count; i++)
|
||||
{
|
||||
if (nodes[i] is not InventoryCategoryNode cat)
|
||||
continue;
|
||||
|
||||
childCount++;
|
||||
float w = cat.Width;
|
||||
if (w > maxChildWidth) maxChildWidth = w;
|
||||
}
|
||||
|
||||
if (childCount == 0)
|
||||
{
|
||||
ResizeWindow(MinWindowWidth, MinWindowHeight, recalcLayout: true);
|
||||
return;
|
||||
}
|
||||
|
||||
float requiredWidth = maxChildWidth + (ContentStartPosition.X * 2);
|
||||
float finalWidth = Math.Clamp(requiredWidth, MinWindowWidth, MaxWindowWidth);
|
||||
|
||||
float contentWidth = finalWidth - (ContentStartPosition.X * 2);
|
||||
|
||||
float footerSpace = HasFooter ? FooterHeight + FooterTopSpacing : 0;
|
||||
float gridBudget = Math.Max(0f, MaxWindowHeight - footerSpace);
|
||||
|
||||
CategoriesNode.Position = ContentStartPosition;
|
||||
CategoriesNode.Size = new Vector2(contentWidth, gridBudget);
|
||||
|
||||
CategoriesNode.RecalculateLayout();
|
||||
|
||||
float requiredGridHeight = CategoriesNode.GetRequiredHeight();
|
||||
float requiredContentHeight = requiredGridHeight + footerSpace;
|
||||
|
||||
float requiredWindowHeight = requiredContentHeight + ContentStartPosition.Y + ContentStartPosition.X;
|
||||
float finalHeight = Math.Clamp(requiredWindowHeight, MinWindowHeight, MaxWindowHeight);
|
||||
|
||||
ResizeWindow(finalWidth, finalHeight, recalcLayout: false);
|
||||
}
|
||||
|
||||
protected void ResizeWindow(float width, float height, bool recalcLayout)
|
||||
{
|
||||
SetWindowSize(width, height);
|
||||
LayoutContent();
|
||||
|
||||
if (recalcLayout)
|
||||
CategoriesNode.RecalculateLayout();
|
||||
}
|
||||
|
||||
protected void ResizeWindow(float width, float height)
|
||||
=> ResizeWindow(width, height, recalcLayout: true);
|
||||
|
||||
protected override void OnFinalize(AtkUnitBase* addon)
|
||||
{
|
||||
HoverSubscribed.Clear();
|
||||
RefreshQueued = false;
|
||||
RefreshAutosizeQueued = false;
|
||||
|
||||
base.OnFinalize(addon);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using AetherBags.Helpers;
|
||||
using AetherBags.Inventory;
|
||||
using AetherBags.Inventory.State;
|
||||
using Dalamud.Game.Command;
|
||||
|
||||
namespace AetherBags.Commands;
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using AetherBags.Inventory.Items;
|
||||
|
||||
namespace AetherBags.Inventory;
|
||||
namespace AetherBags.Inventory.Categories;
|
||||
|
||||
public readonly record struct CategorizedInventory(uint Key, CategoryInfo Category, List<ItemInfo> Items);
|
||||
+3
-2
@@ -1,9 +1,10 @@
|
||||
using AetherBags.Configuration;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AetherBags.Configuration;
|
||||
using AetherBags.Inventory.Items;
|
||||
|
||||
namespace AetherBags.Inventory;
|
||||
namespace AetherBags.Inventory.Categories;
|
||||
|
||||
public static class CategoryBucketManager
|
||||
{
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using System.Numerics;
|
||||
using KamiToolKit.Classes;
|
||||
|
||||
namespace AetherBags.Inventory;
|
||||
namespace AetherBags.Inventory.Categories;
|
||||
|
||||
public class CategoryInfo
|
||||
{
|
||||
+2
-1
@@ -1,8 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using AetherBags.Inventory.Items;
|
||||
|
||||
namespace AetherBags.Inventory;
|
||||
namespace AetherBags.Inventory.Categories;
|
||||
|
||||
public static class InventoryFilter
|
||||
{
|
||||
+3
-2
@@ -1,8 +1,9 @@
|
||||
using AetherBags.Configuration;
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
using AetherBags.Configuration;
|
||||
using AetherBags.Inventory.Items;
|
||||
|
||||
namespace AetherBags.Inventory;
|
||||
namespace AetherBags.Inventory.Categories;
|
||||
|
||||
internal static class UserCategoryMatcher
|
||||
{
|
||||
+1
-1
@@ -4,7 +4,7 @@ using FFXIVClientStructs.FFXIV.Client.UI.Agent;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI.Arrays;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
|
||||
|
||||
namespace AetherBags.Inventory;
|
||||
namespace AetherBags.Inventory.Context;
|
||||
|
||||
public static unsafe class InventoryContextState
|
||||
{
|
||||
+1
-1
@@ -2,7 +2,7 @@ using System.Collections.Generic;
|
||||
using Lumina.Excel.Sheets;
|
||||
using Lumina.Text.ReadOnly;
|
||||
|
||||
namespace AetherBags.Inventory;
|
||||
namespace AetherBags.Inventory.Context;
|
||||
|
||||
public class InventoryNotificationState
|
||||
{
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace AetherBags.Inventory;
|
||||
namespace AetherBags.Inventory.Items;
|
||||
|
||||
public readonly struct InventoryStats
|
||||
{
|
||||
@@ -1,11 +1,12 @@
|
||||
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||
using Lumina.Excel;
|
||||
using Lumina.Excel.Sheets;
|
||||
using System;
|
||||
using System.Numerics;
|
||||
using System.Text.RegularExpressions;
|
||||
using AetherBags.Inventory.Context;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||
using Lumina.Excel;
|
||||
using Lumina.Excel.Sheets;
|
||||
|
||||
namespace AetherBags.Inventory;
|
||||
namespace AetherBags.Inventory.Items;
|
||||
|
||||
public sealed class ItemInfo : IEquatable<ItemInfo>
|
||||
{
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||
|
||||
namespace AetherBags.Inventory;
|
||||
namespace AetherBags.Inventory.Items;
|
||||
|
||||
public record LootedItemInfo(int Index, InventoryItem Item, int Quantity);
|
||||
@@ -0,0 +1,9 @@
|
||||
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||
|
||||
namespace AetherBags.Inventory.Scanning;
|
||||
|
||||
public struct AggregatedItem
|
||||
{
|
||||
public InventoryItem First;
|
||||
public int Total;
|
||||
}
|
||||
+48
-15
@@ -1,8 +1,9 @@
|
||||
using AetherBags.Configuration;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||
using System.Collections.Generic;
|
||||
using AetherBags.Configuration;
|
||||
using AetherBags.Inventory.Items;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||
|
||||
namespace AetherBags.Inventory;
|
||||
namespace AetherBags.Inventory.Scanning;
|
||||
|
||||
public static unsafe class InventoryScanner
|
||||
{
|
||||
@@ -46,20 +47,30 @@ public static unsafe class InventoryScanner
|
||||
public static ulong MakeNaturalSlotKey(InventoryType container, int slot)
|
||||
=> ((ulong)(uint)container << 32) | (uint)slot;
|
||||
|
||||
// Backwards compatible
|
||||
public static void ScanBags(
|
||||
InventoryManager* inventoryManager,
|
||||
InventoryStackMode stackMode,
|
||||
Dictionary<ulong, AggregatedItem> aggByKey)
|
||||
=> ScanInventories(inventoryManager, stackMode, aggByKey, InventorySourceType.MainBags);
|
||||
|
||||
public static void ScanInventories(
|
||||
InventoryManager* inventoryManager,
|
||||
InventoryStackMode stackMode,
|
||||
Dictionary<ulong, AggregatedItem> aggByKey,
|
||||
InventorySourceType source)
|
||||
{
|
||||
aggByKey.Clear();
|
||||
|
||||
var inventories = InventorySourceDefinitions.GetInventories(source);
|
||||
|
||||
int scannedSlots = 0;
|
||||
int nonEmptySlots = 0;
|
||||
int collisions = 0;
|
||||
|
||||
for (int inventoryIndex = 0; inventoryIndex < BagInventories.Length; inventoryIndex++)
|
||||
for (int inventoryIndex = 0; inventoryIndex < inventories.Length; inventoryIndex++)
|
||||
{
|
||||
var inventoryType = BagInventories[inventoryIndex];
|
||||
var inventoryType = inventories[inventoryIndex];
|
||||
var container = inventoryManager->GetInventoryContainer(inventoryType);
|
||||
if (container == null)
|
||||
{
|
||||
@@ -164,16 +175,38 @@ public static unsafe class InventoryScanner
|
||||
public static InventoryContainer* GetInventoryContainer(InventoryType inventoryType)
|
||||
=> InventoryManager.Instance()->GetInventoryContainer(inventoryType);
|
||||
|
||||
// Backwards compability TODO: Remove
|
||||
public static string GetEmptyItemSlotsString()
|
||||
{
|
||||
uint empty = InventoryManager.Instance()->GetEmptySlotsInBag();
|
||||
uint used = 140 - empty;
|
||||
return $"{used}/140";
|
||||
}
|
||||
}
|
||||
=> GetEmptySlotsString(InventorySourceType. MainBags);
|
||||
|
||||
public struct AggregatedItem
|
||||
{
|
||||
public InventoryItem First;
|
||||
public int Total;
|
||||
public static string GetEmptySlotsString(InventorySourceType source)
|
||||
{
|
||||
int total = InventorySourceDefinitions.GetTotalSlots(source);
|
||||
uint empty = source switch
|
||||
{
|
||||
InventorySourceType.MainBags => InventoryManager.Instance()->GetEmptySlotsInBag(),
|
||||
InventorySourceType.SaddleBag => GetEmptySlotsInContainer(InventorySourceDefinitions.SaddleBag),
|
||||
InventorySourceType.Retainer => GetEmptySlotsInContainer(InventorySourceDefinitions.Retainer),
|
||||
_ => 0,
|
||||
};
|
||||
uint used = (uint)total - empty;
|
||||
return $"{used}/{total}";
|
||||
}
|
||||
|
||||
private static uint GetEmptySlotsInContainer(InventoryType[] inventories)
|
||||
{
|
||||
uint empty = 0;
|
||||
var inventoryManager = InventoryManager.Instance();
|
||||
foreach (var inv in inventories)
|
||||
{
|
||||
var container = inventoryManager->GetInventoryContainer(inv);
|
||||
if (container == null) continue;
|
||||
for (int i = 0; i < container->Size; i++)
|
||||
{
|
||||
if (container->Items[i]. ItemId == 0)
|
||||
empty++;
|
||||
}
|
||||
}
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||
|
||||
namespace AetherBags.Inventory.Scanning;
|
||||
|
||||
public enum InventorySourceType
|
||||
{
|
||||
MainBags,
|
||||
SaddleBag,
|
||||
Retainer,
|
||||
}
|
||||
|
||||
public static class InventorySourceDefinitions
|
||||
{
|
||||
public static readonly InventoryType[] MainBags =
|
||||
[
|
||||
InventoryType.Inventory1,
|
||||
InventoryType.Inventory2,
|
||||
InventoryType.Inventory3,
|
||||
InventoryType.Inventory4,
|
||||
];
|
||||
|
||||
public static readonly InventoryType[] SaddleBag =
|
||||
[
|
||||
InventoryType.SaddleBag1,
|
||||
InventoryType.SaddleBag2,
|
||||
InventoryType.PremiumSaddleBag1,
|
||||
InventoryType.PremiumSaddleBag2,
|
||||
];
|
||||
|
||||
public static readonly InventoryType[] Retainer =
|
||||
[
|
||||
InventoryType.RetainerPage1,
|
||||
InventoryType.RetainerPage2,
|
||||
InventoryType.RetainerPage3,
|
||||
InventoryType.RetainerPage4,
|
||||
InventoryType.RetainerPage5,
|
||||
InventoryType.RetainerPage6,
|
||||
InventoryType.RetainerPage7,
|
||||
];
|
||||
|
||||
public static InventoryType[] GetInventories(InventorySourceType source) => source switch
|
||||
{
|
||||
InventorySourceType.MainBags => MainBags,
|
||||
InventorySourceType.SaddleBag => SaddleBag,
|
||||
InventorySourceType.Retainer => Retainer,
|
||||
_ => MainBags,
|
||||
};
|
||||
|
||||
public static int GetTotalSlots(InventorySourceType source) => source switch
|
||||
{
|
||||
InventorySourceType.MainBags => 140, // 4 * 35
|
||||
InventorySourceType.SaddleBag => 70, // 2 * 35 TODO: Premium adds another 70
|
||||
InventorySourceType.Retainer => 175, // 7 * 25
|
||||
_ => 140,
|
||||
};
|
||||
}
|
||||
+7
-3
@@ -1,12 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AetherBags.Configuration;
|
||||
using AetherBags.Currency;
|
||||
using AetherBags.Inventory.Categories;
|
||||
using AetherBags.Inventory.Context;
|
||||
using AetherBags.Inventory.Items;
|
||||
using AetherBags.Inventory.Scanning;
|
||||
using Dalamud.Game.Inventory;
|
||||
using Dalamud.Game.Inventory.InventoryEventArgTypes;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace AetherBags.Inventory;
|
||||
namespace AetherBags.Inventory.State;
|
||||
|
||||
public static unsafe class InventoryState
|
||||
{
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AetherBags.Configuration;
|
||||
using AetherBags.Inventory.Categories;
|
||||
using AetherBags.Inventory.Items;
|
||||
using AetherBags.Inventory.Scanning;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||
|
||||
namespace AetherBags.Inventory.State;
|
||||
|
||||
public abstract class InventoryStateBase
|
||||
{
|
||||
protected readonly Dictionary<ulong, AggregatedItem> AggByKey = new(capacity: 512);
|
||||
protected readonly Dictionary<ulong, ItemInfo> ItemInfoByKey = new(capacity: 512);
|
||||
protected readonly Dictionary<uint, CategoryBucket> BucketsByKey = new(capacity: 256);
|
||||
protected readonly List<uint> SortedCategoryKeys = new(capacity: 256);
|
||||
protected readonly List<CategorizedInventory> AllCategories = new(capacity: 256);
|
||||
protected readonly List<CategorizedInventory> FilteredCategories = new(capacity: 256);
|
||||
protected readonly List<UserCategoryDefinition> UserCategoriesSortedScratch = new(capacity: 64);
|
||||
protected readonly List<ulong> RemoveKeysScratch = new(capacity: 256);
|
||||
protected readonly HashSet<ulong> ClaimedKeys = new(capacity: 512);
|
||||
|
||||
public abstract InventorySourceType SourceType { get; }
|
||||
public abstract InventoryType[] Inventories { get; }
|
||||
|
||||
public virtual unsafe void RefreshFromGame()
|
||||
{
|
||||
InventoryManager* inventoryManager = InventoryManager.Instance();
|
||||
if (inventoryManager == null)
|
||||
{
|
||||
ClearAll();
|
||||
return;
|
||||
}
|
||||
|
||||
var config = AetherBags.System.Config;
|
||||
InventoryStackMode stackMode = config.General.StackMode;
|
||||
|
||||
AggByKey.Clear();
|
||||
ItemInfoByKey.Clear();
|
||||
SortedCategoryKeys.Clear();
|
||||
AllCategories.Clear();
|
||||
FilteredCategories.Clear();
|
||||
ClaimedKeys.Clear();
|
||||
|
||||
InventoryScanner.ScanInventories(inventoryManager, stackMode, AggByKey, SourceType);
|
||||
CategoryBucketManager.ResetBuckets(BucketsByKey);
|
||||
InventoryScanner.BuildItemInfos(AggByKey, ItemInfoByKey);
|
||||
|
||||
OnPostScan();
|
||||
|
||||
ApplyCategories(config);
|
||||
|
||||
InventoryScanner.PruneStaleItemInfos(AggByKey, ItemInfoByKey, RemoveKeysScratch);
|
||||
CategoryBucketManager.SortBucketsAndBuildKeyList(BucketsByKey, SortedCategoryKeys);
|
||||
CategoryBucketManager.BuildCategorizedList(BucketsByKey, SortedCategoryKeys, AllCategories);
|
||||
}
|
||||
|
||||
protected virtual void OnPostScan()
|
||||
{
|
||||
}
|
||||
|
||||
protected virtual void ApplyCategories(SystemConfiguration config)
|
||||
{
|
||||
bool userCategoriesEnabled = config.Categories.UserCategoriesEnabled;
|
||||
bool gameCategoriesEnabled = config.Categories.GameCategoriesEnabled;
|
||||
var userCategories = config.Categories.UserCategories.Where(c => c.Enabled).ToList();
|
||||
|
||||
if (userCategoriesEnabled && userCategories.Count > 0)
|
||||
{
|
||||
CategoryBucketManager.BucketByUserCategories(
|
||||
ItemInfoByKey, userCategories, BucketsByKey, ClaimedKeys, UserCategoriesSortedScratch);
|
||||
}
|
||||
|
||||
if (gameCategoriesEnabled)
|
||||
{
|
||||
CategoryBucketManager.BucketByGameCategories(
|
||||
ItemInfoByKey, BucketsByKey, ClaimedKeys, userCategoriesEnabled);
|
||||
}
|
||||
else
|
||||
{
|
||||
CategoryBucketManager.BucketUnclaimedToMisc(
|
||||
ItemInfoByKey, BucketsByKey, ClaimedKeys, userCategoriesEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<CategorizedInventory> GetCategories(string filter = "", bool invert = false)
|
||||
=> InventoryFilter.FilterCategories(AllCategories, BucketsByKey, FilteredCategories, filter, invert);
|
||||
|
||||
public string GetEmptySlotsString() => InventoryScanner.GetEmptySlotsString(SourceType);
|
||||
|
||||
protected virtual void ClearAll()
|
||||
{
|
||||
AggByKey.Clear();
|
||||
ItemInfoByKey.Clear();
|
||||
|
||||
foreach (var kvp in BucketsByKey)
|
||||
{
|
||||
kvp.Value.Items.Clear();
|
||||
kvp.Value.FilteredItems.Clear();
|
||||
kvp.Value.Used = false;
|
||||
}
|
||||
|
||||
SortedCategoryKeys.Clear();
|
||||
AllCategories.Clear();
|
||||
FilteredCategories.Clear();
|
||||
RemoveKeysScratch.Clear();
|
||||
ClaimedKeys.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using AetherBags.Inventory.Context;
|
||||
using AetherBags.Inventory.Scanning;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||
|
||||
namespace AetherBags.Inventory.State;
|
||||
|
||||
public class MainBagState : InventoryStateBase
|
||||
{
|
||||
public override InventorySourceType SourceType => InventorySourceType.MainBags;
|
||||
public override InventoryType[] Inventories => InventorySourceDefinitions.MainBags;
|
||||
|
||||
protected override void OnPostScan()
|
||||
{
|
||||
InventoryContextState.RefreshMaps();
|
||||
InventoryContextState.RefreshBlockedSlots();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using AetherBags.Inventory.Scanning;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||
|
||||
namespace AetherBags.Inventory.State;
|
||||
|
||||
public class SaddleBagState : InventoryStateBase
|
||||
{
|
||||
public override InventorySourceType SourceType => InventorySourceType.SaddleBag;
|
||||
public override InventoryType[] Inventories => InventorySourceDefinitions.SaddleBag;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Numerics;
|
||||
using AetherBags.Configuration;
|
||||
using AetherBags.Inventory;
|
||||
using AetherBags.Inventory.Categories;
|
||||
using AetherBags.Nodes.Color;
|
||||
using Dalamud.Utility;
|
||||
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||
|
||||
@@ -2,6 +2,8 @@ using System;
|
||||
using System.Numerics;
|
||||
using AetherBags.Helpers;
|
||||
using AetherBags.Inventory;
|
||||
using AetherBags.Inventory.Categories;
|
||||
using AetherBags.Inventory.Items;
|
||||
using AetherBags.Nodes.Layout;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||
@@ -33,6 +35,7 @@ public class InventoryCategoryNode : SimpleComponentNode
|
||||
private string _fullHeaderText = string.Empty;
|
||||
|
||||
public event Action<InventoryCategoryNode, bool>? HeaderHoverChanged;
|
||||
public Action? OnRefreshRequested { get; set; }
|
||||
|
||||
public InventoryCategoryNode()
|
||||
{
|
||||
@@ -287,7 +290,7 @@ public class InventoryCategoryNode : SimpleComponentNode
|
||||
Services.Logger.Debug($"[OnPayload] Source and target are in the same container group; no move performed");
|
||||
node.Payload = payload;
|
||||
node.IconId = item.IconId;
|
||||
System.AddonInventoryWindow.ManualInventoryRefresh();
|
||||
OnRefreshRequested?.Invoke();
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -297,6 +300,6 @@ public class InventoryCategoryNode : SimpleComponentNode
|
||||
sourceLocation.Container, sourceLocation.Slot,
|
||||
targetLocation.Container, targetLocation.Slot
|
||||
);
|
||||
System.AddonInventoryWindow.ManualInventoryRefresh();
|
||||
OnRefreshRequested?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Numerics;
|
||||
using AetherBags.Inventory;
|
||||
using AetherBags.Inventory.Items;
|
||||
using Dalamud.Game.ClientState.Keys;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AetherBags.Currency;
|
||||
using AetherBags.Inventory;
|
||||
using AetherBags.Inventory.State;
|
||||
using AetherBags.Nodes.Currency;
|
||||
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||
using KamiToolKit.Classes;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Numerics;
|
||||
using AetherBags.Inventory;
|
||||
using AetherBags.Inventory.Context;
|
||||
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||
using KamiToolKit.Classes;
|
||||
using KamiToolKit.Classes.Timelines;
|
||||
|
||||
@@ -5,6 +5,7 @@ using AetherBags.Commands;
|
||||
using AetherBags.Helpers;
|
||||
using AetherBags.Hooks;
|
||||
using AetherBags.Inventory;
|
||||
using AetherBags.Inventory.State;
|
||||
using Dalamud.Plugin;
|
||||
using KamiToolKit;
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace AetherBags;
|
||||
public static class System
|
||||
{
|
||||
public static AddonInventoryWindow AddonInventoryWindow { get; set; } = null!;
|
||||
public static AddonInventoryWindow AddonSaddleBagWindow { get; set; } = null!;
|
||||
public static AddonInventoryWindow AddonRetainerWindow { get; set; } = null!;
|
||||
public static AddonConfigurationWindow AddonConfigurationWindow { get; set; } = null!;
|
||||
public static SystemConfiguration Config { get; set; } = null!;
|
||||
}
|
||||
Reference in New Issue
Block a user