mirror of
https://github.com/zDevelopers/ImageOnMap.git
synced 2024-11-25 19:45:52 +01:00
New Sign-based Prompt GUI API.
* NEW: Added new Callback class. * NEW: Added ReflectionUtils module. * NEW: Added new PromptGui class. * NEW: MapDetailGui: Implement renaming using new prompt API. * BUG: ExplorerGUI: Fix a crash when setting null data. * BUG: InventoryGUI: Fix titles longer than 32 characters.
This commit is contained in:
parent
48dbdbd9d4
commit
96093898c8
134
src/main/java/fr/moribus/imageonmap/ReflectionUtils.java
Normal file
134
src/main/java/fr/moribus/imageonmap/ReflectionUtils.java
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2013 Moribus
|
||||||
|
* Copyright (C) 2015 ProkopyL <prokopylmc@gmail.com>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package fr.moribus.imageonmap;
|
||||||
|
|
||||||
|
import java.lang.reflect.Constructor;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.InvocationTargetException;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
|
||||||
|
abstract public class ReflectionUtils
|
||||||
|
{
|
||||||
|
static public String getBukkitPackageVersion()
|
||||||
|
{
|
||||||
|
return getBukkitPackageName().substring("org.bukkit.craftbukkit.".length());
|
||||||
|
}
|
||||||
|
|
||||||
|
static public String getBukkitPackageName()
|
||||||
|
{
|
||||||
|
return Bukkit.getServer().getClass().getPackage().getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
static public String getMinecraftPackageName()
|
||||||
|
{
|
||||||
|
return "net.minecraft.server." + getBukkitPackageVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
static public Class getBukkitClassByName(String name) throws ClassNotFoundException
|
||||||
|
{
|
||||||
|
return Class.forName(getBukkitPackageName() + "." + name);
|
||||||
|
}
|
||||||
|
|
||||||
|
static public Class getMinecraftClassByName(String name) throws ClassNotFoundException
|
||||||
|
{
|
||||||
|
return Class.forName(getMinecraftPackageName() + "." + name);
|
||||||
|
}
|
||||||
|
|
||||||
|
static public Object getFieldValue(Class hClass, Object instance, String name)
|
||||||
|
throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException
|
||||||
|
{
|
||||||
|
return getField(hClass, name).get(instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
static public Object getFieldValue(Object instance, String name)
|
||||||
|
throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException
|
||||||
|
{
|
||||||
|
return getFieldValue(instance.getClass(), instance, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
static public Field getField(Class klass, String name) throws NoSuchFieldException
|
||||||
|
{
|
||||||
|
Field field = klass.getDeclaredField(name);
|
||||||
|
field.setAccessible(true);
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
static public Field getField(Class klass, Class type) throws NoSuchFieldException
|
||||||
|
{
|
||||||
|
for(Field field : klass.getDeclaredFields())
|
||||||
|
{
|
||||||
|
if(field.getType().equals(type))
|
||||||
|
{
|
||||||
|
field.setAccessible(true);
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new NoSuchFieldException("Class " + klass.getName() + " does not define any field of type " + type.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
static public void setFieldValue(Object instance, String name, Object value)
|
||||||
|
throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException
|
||||||
|
{
|
||||||
|
setFieldValue(instance.getClass(), instance, name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
static public void setFieldValue(Class hClass, Object instance, String name, Object value)
|
||||||
|
throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException
|
||||||
|
{
|
||||||
|
getField(hClass, name).set(instance, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
static public Object call(Class hClass, String name, Object ... parameters)
|
||||||
|
throws NoSuchMethodException, IllegalAccessException, IllegalArgumentException, InvocationTargetException
|
||||||
|
{
|
||||||
|
return call(hClass, null, name, parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
static public Object call(Object instance, String name, Object ... parameters)
|
||||||
|
throws NoSuchMethodException, IllegalAccessException, IllegalArgumentException, InvocationTargetException
|
||||||
|
{
|
||||||
|
return call(instance.getClass(), instance, name, parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
static public Object call(Class hClass, Object instance, String name, Object ... parameters)
|
||||||
|
throws NoSuchMethodException, IllegalAccessException, IllegalArgumentException, InvocationTargetException
|
||||||
|
{
|
||||||
|
Method method = hClass.getMethod(name, getTypes(parameters));
|
||||||
|
return method.invoke(instance, parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
static public Object instanciate(Class hClass, Object ... parameters)
|
||||||
|
throws NoSuchMethodException, InstantiationException,
|
||||||
|
IllegalAccessException, IllegalArgumentException, InvocationTargetException
|
||||||
|
{
|
||||||
|
Constructor constructor = hClass.getConstructor(getTypes(parameters));
|
||||||
|
return constructor.newInstance(parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
static public Class[] getTypes(Object[] objects)
|
||||||
|
{
|
||||||
|
Class[] types = new Class[objects.length];
|
||||||
|
for(int i = 0; i < objects.length; i++)
|
||||||
|
{
|
||||||
|
types[i] = objects[i].getClass();
|
||||||
|
}
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2013 Moribus
|
||||||
|
* Copyright (C) 2015 ProkopyL <prokopylmc@gmail.com>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package fr.moribus.imageonmap.guiproko.core;
|
||||||
|
|
||||||
|
public interface Callback<T>
|
||||||
|
{
|
||||||
|
public void call(T parameter);
|
||||||
|
}
|
@ -98,10 +98,11 @@ abstract public class ExplorerGui<T> extends ActionGui
|
|||||||
protected void setData(T[] data, int dataWidth)
|
protected void setData(T[] data, int dataWidth)
|
||||||
{
|
{
|
||||||
this.data = data;
|
this.data = data;
|
||||||
|
int dataLength = data == null ? 0 : data.length;
|
||||||
if(dataWidth > 0)
|
if(dataWidth > 0)
|
||||||
setDataShape(dataWidth, (int) Math.ceil((double) data.length / (double) dataWidth));
|
setDataShape(dataWidth, (int) Math.ceil((double) dataLength / (double) dataWidth));
|
||||||
else
|
else
|
||||||
setDataShape(0, data.length);
|
setDataShape(0, dataLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -35,6 +35,7 @@ abstract public class InventoryGui extends Gui
|
|||||||
static protected final int INVENTORY_ROW_SIZE = 9;
|
static protected final int INVENTORY_ROW_SIZE = 9;
|
||||||
static protected final int MAX_INVENTORY_COLUMN_SIZE = 6;
|
static protected final int MAX_INVENTORY_COLUMN_SIZE = 6;
|
||||||
static protected final int MAX_INVENTORY_SIZE = INVENTORY_ROW_SIZE * MAX_INVENTORY_COLUMN_SIZE;
|
static protected final int MAX_INVENTORY_SIZE = INVENTORY_ROW_SIZE * MAX_INVENTORY_COLUMN_SIZE;
|
||||||
|
static protected final int MAX_TITLE_LENGTH = 32;
|
||||||
|
|
||||||
public InventoryGui()
|
public InventoryGui()
|
||||||
{
|
{
|
||||||
@ -197,7 +198,14 @@ abstract public class InventoryGui extends Gui
|
|||||||
* It will be applied on the next GUI update.
|
* It will be applied on the next GUI update.
|
||||||
* @param title The new title of the inventory
|
* @param title The new title of the inventory
|
||||||
*/
|
*/
|
||||||
protected void setTitle(String title){this.title = title;}
|
protected void setTitle(String title)
|
||||||
|
{
|
||||||
|
if(title != null && title.length() > MAX_TITLE_LENGTH)
|
||||||
|
{
|
||||||
|
title = title.substring(0, MAX_TITLE_LENGTH - 4) + "...";
|
||||||
|
}
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
/** @return The underlying inventory, or null if the Gui has not been opened yet. */
|
/** @return The underlying inventory, or null if the Gui has not been opened yet. */
|
||||||
public Inventory getInventory() { return inventory; }
|
public Inventory getInventory() { return inventory; }
|
||||||
|
257
src/main/java/fr/moribus/imageonmap/guiproko/core/PromptGui.java
Normal file
257
src/main/java/fr/moribus/imageonmap/guiproko/core/PromptGui.java
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2013 Moribus
|
||||||
|
* Copyright (C) 2015 ProkopyL <prokopylmc@gmail.com>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package fr.moribus.imageonmap.guiproko.core;
|
||||||
|
|
||||||
|
import fr.moribus.imageonmap.ImageOnMap;
|
||||||
|
import fr.moribus.imageonmap.PluginLogger;
|
||||||
|
import fr.moribus.imageonmap.ReflectionUtils;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Chunk;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.block.Sign;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.block.SignChangeEvent;
|
||||||
|
|
||||||
|
public class PromptGui extends Gui
|
||||||
|
{
|
||||||
|
static private final int SIGN_LINES_COUNT = 4;
|
||||||
|
static private final int SIGN_COLUMNS_COUNT = 15;
|
||||||
|
|
||||||
|
static private boolean isInitialized = false;
|
||||||
|
|
||||||
|
/* ===== Reflection to Sign API ===== */
|
||||||
|
static private Field fieldSign = null;//CraftSign.sign
|
||||||
|
static private Field fieldIsEditable = null;//TileEntitySign.isEditable
|
||||||
|
static private Method methodGetHandle = null;//CraftPlayer.getHandle()
|
||||||
|
static private Method methodOpenSign = null;//EntityHuman.openSign()
|
||||||
|
static private Field fieldSignEditor = null;//TileEntitySign.k{EntityHuman}
|
||||||
|
static private Class classTileEntitySign = null;//CraftBlock.class
|
||||||
|
|
||||||
|
static public boolean isAvailable()
|
||||||
|
{
|
||||||
|
if(!isInitialized) init();
|
||||||
|
return fieldSign != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static public void prompt(Player owner, Callback<String> callback)
|
||||||
|
{
|
||||||
|
prompt(owner, callback, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
static public void prompt(Player owner, Callback<String> callback, String contents)
|
||||||
|
{
|
||||||
|
Gui.open(owner, new PromptGui(callback, contents));
|
||||||
|
}
|
||||||
|
|
||||||
|
static private void init()
|
||||||
|
{
|
||||||
|
isInitialized = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Class CraftSign = ReflectionUtils.getBukkitClassByName("block.CraftSign");
|
||||||
|
classTileEntitySign = ReflectionUtils.getMinecraftClassByName("TileEntitySign");
|
||||||
|
Class CraftPlayer = ReflectionUtils.getBukkitClassByName("entity.CraftPlayer");
|
||||||
|
Class EntityHuman = ReflectionUtils.getMinecraftClassByName("EntityHuman");
|
||||||
|
|
||||||
|
|
||||||
|
fieldSign = ReflectionUtils.getField(CraftSign, "sign");
|
||||||
|
fieldIsEditable = ReflectionUtils.getField(classTileEntitySign, "isEditable");
|
||||||
|
methodGetHandle = CraftPlayer.getDeclaredMethod("getHandle");
|
||||||
|
methodOpenSign = EntityHuman.getDeclaredMethod("openSign", classTileEntitySign);
|
||||||
|
fieldSignEditor = ReflectionUtils.getField(classTileEntitySign, EntityHuman);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
PluginLogger.error("Unable to initialize Sign Prompt API", ex);
|
||||||
|
fieldSign = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Callback<String> callback;
|
||||||
|
private Location signLocation;
|
||||||
|
private String contents;
|
||||||
|
|
||||||
|
public PromptGui(Callback<String> callback, String contents)
|
||||||
|
{
|
||||||
|
this(callback);
|
||||||
|
this.contents = contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PromptGui(Callback<String> callback)
|
||||||
|
{
|
||||||
|
super();
|
||||||
|
registerListener(PromptGuiListener.class);
|
||||||
|
if(!isAvailable()) throw new IllegalStateException("Sign-based prompt GUI are not available");
|
||||||
|
this.callback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void open(final Player player)
|
||||||
|
{
|
||||||
|
super.open(player);
|
||||||
|
|
||||||
|
signLocation = findAvailableLocation(player);
|
||||||
|
Block block = player.getWorld().getBlockAt(signLocation);
|
||||||
|
block.setType(Material.SIGN_POST, false);
|
||||||
|
final Sign sign = (Sign) block.getState();
|
||||||
|
setSignContents(sign, contents);
|
||||||
|
sign.update();
|
||||||
|
|
||||||
|
Bukkit.getScheduler().scheduleSyncDelayedTask(ImageOnMap.getPlugin(), new Runnable()
|
||||||
|
{
|
||||||
|
@Override
|
||||||
|
public void run()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Object signTE = fieldSign.get(sign);
|
||||||
|
Object playerEntity = methodGetHandle.invoke(player);
|
||||||
|
methodOpenSign.invoke(playerEntity, signTE);
|
||||||
|
}
|
||||||
|
catch(Throwable ex)
|
||||||
|
{
|
||||||
|
PluginLogger.error("Error while opening Sign prompt", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 3);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close()
|
||||||
|
{
|
||||||
|
Block block = getPlayer().getWorld().getBlockAt(signLocation);
|
||||||
|
block.setType(Material.AIR);
|
||||||
|
super.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validate(String[] lines)
|
||||||
|
{
|
||||||
|
callback.call(getSignContents(lines));
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
static private String getSignContents(String[] lines)
|
||||||
|
{
|
||||||
|
String content = lines[0].trim();
|
||||||
|
|
||||||
|
for(int i = 1; i < lines.length; i++)
|
||||||
|
{
|
||||||
|
if(lines[i] == null || lines[i].isEmpty()) continue;
|
||||||
|
content += " " + lines[i].trim();
|
||||||
|
}
|
||||||
|
return content.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
static private void setSignContents(Sign sign, String content)
|
||||||
|
{
|
||||||
|
String[] lines = new String[SIGN_LINES_COUNT + 1];
|
||||||
|
String curLine;
|
||||||
|
int curLineIndex = 0, spacePos;
|
||||||
|
|
||||||
|
if(content != null)
|
||||||
|
{
|
||||||
|
lines[0] = content;
|
||||||
|
while(curLineIndex < SIGN_LINES_COUNT)
|
||||||
|
{
|
||||||
|
curLine = lines[curLineIndex];
|
||||||
|
if(curLine.length() <= SIGN_COLUMNS_COUNT)
|
||||||
|
break;
|
||||||
|
|
||||||
|
spacePos = curLine.lastIndexOf(' ', SIGN_COLUMNS_COUNT);
|
||||||
|
if(spacePos < 0) break;
|
||||||
|
lines[curLineIndex + 1] = curLine.substring(spacePos + 1);
|
||||||
|
lines[curLineIndex] = curLine.substring(0, spacePos);
|
||||||
|
curLineIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for(int i = SIGN_LINES_COUNT; i --> 0;)
|
||||||
|
{
|
||||||
|
sign.setLine(i, lines[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static private Location findAvailableLocation(Player player)
|
||||||
|
{
|
||||||
|
World world = player.getWorld();
|
||||||
|
Chunk playerChunk = player.getLocation().getChunk();
|
||||||
|
Chunk firstChunk = world.getChunkAt(playerChunk.getX() - 1, playerChunk.getZ() - 1);
|
||||||
|
Location firstLoc = firstChunk.getBlock(0, 255, 0).getLocation();
|
||||||
|
Location loc;
|
||||||
|
|
||||||
|
for(int i = 48; i --> 0;)
|
||||||
|
{
|
||||||
|
for(int j = 0; j --> -10;)
|
||||||
|
{
|
||||||
|
for(int k = 48; k --> 0;)
|
||||||
|
{
|
||||||
|
loc = firstLoc.add(i, j, k);
|
||||||
|
if(hasSpace(world, loc))
|
||||||
|
return loc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static private boolean hasSpace(World world, Location loc)
|
||||||
|
{
|
||||||
|
if(!Material.AIR.equals(world.getBlockAt(loc).getType()))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
for(int i = 1; i --> -1;)
|
||||||
|
{
|
||||||
|
for(int j = 1; j --> -1;)
|
||||||
|
{
|
||||||
|
for(int k = 1; k --> -1;)
|
||||||
|
{
|
||||||
|
if(!Material.AIR.equals(world.getBlockAt(
|
||||||
|
loc.getBlockX() + i, loc.getBlockY() + j, loc.getBlockZ() + k)
|
||||||
|
.getType()))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static private final class PromptGuiListener implements Listener
|
||||||
|
{
|
||||||
|
@EventHandler
|
||||||
|
public void onSignChange(SignChangeEvent event)
|
||||||
|
{
|
||||||
|
PromptGui gui = Gui.getOpenGui(event.getPlayer(), PromptGui.class);
|
||||||
|
if(gui == null) return;
|
||||||
|
gui.validate(event.getLines());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -68,6 +68,25 @@ public class MapDetailGui extends ExplorerGui<Void>
|
|||||||
else return super.getEmptyViewItem();
|
else return super.getEmptyViewItem();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GuiAction
|
||||||
|
private void rename()
|
||||||
|
{
|
||||||
|
PromptGui.prompt(getPlayer(), new Callback<String>()
|
||||||
|
{
|
||||||
|
@Override
|
||||||
|
public void call(String newName)
|
||||||
|
{
|
||||||
|
if(newName == null || newName.isEmpty())
|
||||||
|
{
|
||||||
|
getPlayer().sendMessage(ChatColor.RED + "Map names can't be empty.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
map.rename(newName);
|
||||||
|
getPlayer().sendMessage(ChatColor.GRAY + "Map successfuly renamed.");
|
||||||
|
}
|
||||||
|
}, map.getName());
|
||||||
|
}
|
||||||
|
|
||||||
@GuiAction
|
@GuiAction
|
||||||
private void delete()
|
private void delete()
|
||||||
{
|
{
|
||||||
|
Loading…
Reference in New Issue
Block a user