feat: Add npc location exporter
This commit is contained in:
@@ -58,6 +58,7 @@ object PowerUserTools : FirmamentFeature {
|
|||||||
val copyTitle by keyBindingWithDefaultUnbound("copy-title")
|
val copyTitle by keyBindingWithDefaultUnbound("copy-title")
|
||||||
val exportItemStackToRepo by keyBindingWithDefaultUnbound("export-item-stack")
|
val exportItemStackToRepo by keyBindingWithDefaultUnbound("export-item-stack")
|
||||||
val exportUIRecipes by keyBindingWithDefaultUnbound("export-recipe")
|
val exportUIRecipes by keyBindingWithDefaultUnbound("export-recipe")
|
||||||
|
val exportNpcLocation by keyBindingWithDefaultUnbound("export-npc-location")
|
||||||
}
|
}
|
||||||
|
|
||||||
override val config
|
override val config
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
package moe.nea.firmament.features.debug.itemeditor
|
package moe.nea.firmament.features.debug.itemeditor
|
||||||
|
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import net.minecraft.client.network.ClientPlayerEntity
|
||||||
|
import net.minecraft.entity.decoration.ArmorStandEntity
|
||||||
|
import moe.nea.firmament.Firmament
|
||||||
import moe.nea.firmament.annotations.Subscribe
|
import moe.nea.firmament.annotations.Subscribe
|
||||||
import moe.nea.firmament.events.HandledScreenKeyPressedEvent
|
import moe.nea.firmament.events.HandledScreenKeyPressedEvent
|
||||||
|
import moe.nea.firmament.events.WorldKeyboardEvent
|
||||||
import moe.nea.firmament.features.debug.PowerUserTools
|
import moe.nea.firmament.features.debug.PowerUserTools
|
||||||
import moe.nea.firmament.repo.ItemNameLookup
|
import moe.nea.firmament.repo.ItemNameLookup
|
||||||
import moe.nea.firmament.util.MC
|
import moe.nea.firmament.util.MC
|
||||||
|
import moe.nea.firmament.util.SBData
|
||||||
import moe.nea.firmament.util.SHORT_NUMBER_FORMAT
|
import moe.nea.firmament.util.SHORT_NUMBER_FORMAT
|
||||||
import moe.nea.firmament.util.SkyblockId
|
import moe.nea.firmament.util.SkyblockId
|
||||||
|
import moe.nea.firmament.util.async.waitForTextInput
|
||||||
import moe.nea.firmament.util.ifDropLast
|
import moe.nea.firmament.util.ifDropLast
|
||||||
import moe.nea.firmament.util.mc.ScreenUtil.getSlotByIndex
|
import moe.nea.firmament.util.mc.ScreenUtil.getSlotByIndex
|
||||||
import moe.nea.firmament.util.mc.displayNameAccordingToNbt
|
import moe.nea.firmament.util.mc.displayNameAccordingToNbt
|
||||||
import moe.nea.firmament.util.mc.loreAccordingToNbt
|
import moe.nea.firmament.util.mc.loreAccordingToNbt
|
||||||
|
import moe.nea.firmament.util.mc.setSkullOwner
|
||||||
import moe.nea.firmament.util.parseShortNumber
|
import moe.nea.firmament.util.parseShortNumber
|
||||||
import moe.nea.firmament.util.red
|
import moe.nea.firmament.util.red
|
||||||
import moe.nea.firmament.util.removeColorCodes
|
import moe.nea.firmament.util.removeColorCodes
|
||||||
@@ -32,10 +40,46 @@ object ExportRecipe {
|
|||||||
val x = it % 3
|
val x = it % 3
|
||||||
val y = it / 3
|
val y = it / 3
|
||||||
|
|
||||||
(xNames[x].toString() + yNames[y]) to x + y * 9 + 10
|
(yNames[y].toString() + xNames[x].toString()) to x + y * 9 + 10
|
||||||
}
|
}
|
||||||
val resultSlot = 25
|
val resultSlot = 25
|
||||||
|
|
||||||
|
@Subscribe
|
||||||
|
fun exportNpcLocation(event: WorldKeyboardEvent) {
|
||||||
|
if (!event.matches(PowerUserTools.TConfig.exportNpcLocation)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val entity = MC.instance.targetedEntity
|
||||||
|
if (entity == null) {
|
||||||
|
MC.sendChat(tr("firmament.repo.export.npc.noentity", "Could not find entity to export"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Firmament.coroutineScope.launch {
|
||||||
|
val guessName = entity.world.getEntitiesByClass(
|
||||||
|
ArmorStandEntity::class.java,
|
||||||
|
entity.boundingBox.expand(0.1),
|
||||||
|
{ !it.name.string.contains("CLICK") })
|
||||||
|
.firstOrNull()?.customName?.string
|
||||||
|
?: ""
|
||||||
|
val reply = waitForTextInput("$guessName (NPC)", "Export stub")
|
||||||
|
val id = generateName(reply)
|
||||||
|
ItemExporter.exportStub(id, reply) {
|
||||||
|
val playerEntity = entity as? ClientPlayerEntity
|
||||||
|
val textureUrl = playerEntity?.skinTextures?.textureUrl
|
||||||
|
if (textureUrl != null)
|
||||||
|
it.setSkullOwner(playerEntity.uuid, textureUrl)
|
||||||
|
}
|
||||||
|
ItemExporter.modifyJson(id) {
|
||||||
|
val mutJson = it.toMutableMap()
|
||||||
|
mutJson["island"] = JsonPrimitive(SBData.skyblockLocation?.locrawMode ?: "unknown")
|
||||||
|
mutJson["x"] = JsonPrimitive(entity.blockX)
|
||||||
|
mutJson["y"] = JsonPrimitive(entity.blockY)
|
||||||
|
mutJson["z"] = JsonPrimitive(entity.blockZ)
|
||||||
|
JsonObject(mutJson)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Subscribe
|
@Subscribe
|
||||||
fun onRecipeKeyBind(event: HandledScreenKeyPressedEvent) {
|
fun onRecipeKeyBind(event: HandledScreenKeyPressedEvent) {
|
||||||
if (!event.matches(PowerUserTools.TConfig.exportUIRecipes)) {
|
if (!event.matches(PowerUserTools.TConfig.exportUIRecipes)) {
|
||||||
@@ -138,7 +182,7 @@ object ExportRecipe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun generateName(name: String): SkyblockId {
|
fun generateName(name: String): SkyblockId {
|
||||||
return SkyblockId(name.uppercase().replace(" ", "_"))
|
return SkyblockId(name.uppercase().replace(" ", "_").replace("(", "").replace(")", ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun findStackableItemByName(name: String, fallbackToGenerated: Boolean = false): Pair<SkyblockId, Double>? {
|
fun findStackableItemByName(name: String, fallbackToGenerated: Boolean = false): Pair<SkyblockId, Double>? {
|
||||||
|
|||||||
@@ -65,13 +65,20 @@ object ItemExporter {
|
|||||||
exportItem(itemStack)
|
exportItem(itemStack)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun appendRecipe(skyblockId: SkyblockId, recipe: JsonObject) {
|
fun modifyJson(skyblockId: SkyblockId, modify: (JsonObject) -> JsonObject) {
|
||||||
val oldJson = Firmament.json.decodeFromString<JsonObject>(pathFor(skyblockId).readText())
|
val oldJson = Firmament.json.decodeFromString<JsonObject>(pathFor(skyblockId).readText())
|
||||||
|
val newJson = modify(oldJson)
|
||||||
|
pathFor(skyblockId).writeText(Firmament.twoSpaceJson.encodeToString(JsonObject(newJson)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun appendRecipe(skyblockId: SkyblockId, recipe: JsonObject) {
|
||||||
|
modifyJson(skyblockId) { oldJson ->
|
||||||
val mutableJson = oldJson.toMutableMap()
|
val mutableJson = oldJson.toMutableMap()
|
||||||
val recipes = ((mutableJson["recipes"] as JsonArray?) ?: listOf()).toMutableList()
|
val recipes = ((mutableJson["recipes"] as JsonArray?) ?: listOf()).toMutableList()
|
||||||
recipes.add(recipe)
|
recipes.add(recipe)
|
||||||
mutableJson["recipes"] = JsonArray(recipes)
|
mutableJson["recipes"] = JsonArray(recipes)
|
||||||
pathFor(skyblockId).writeText(Firmament.twoSpaceJson.encodeToString(JsonObject(mutableJson)))
|
JsonObject(mutableJson)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Subscribe
|
@Subscribe
|
||||||
@@ -82,7 +89,7 @@ object ItemExporter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun exportStub(skyblockId: SkyblockId, title: String) {
|
fun exportStub(skyblockId: SkyblockId, title: String, extra: (ItemStack) -> Unit = {}) {
|
||||||
exportItem(ItemStack(Items.PLAYER_HEAD).also {
|
exportItem(ItemStack(Items.PLAYER_HEAD).also {
|
||||||
it.displayNameAccordingToNbt = Text.literal(title)
|
it.displayNameAccordingToNbt = Text.literal(title)
|
||||||
it.loreAccordingToNbt = listOf(Text.literal(""))
|
it.loreAccordingToNbt = listOf(Text.literal(""))
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ import moe.nea.firmament.util.transformEachRecursively
|
|||||||
import moe.nea.firmament.util.unformattedString
|
import moe.nea.firmament.util.unformattedString
|
||||||
|
|
||||||
class LegacyItemExporter private constructor(var itemStack: ItemStack) {
|
class LegacyItemExporter private constructor(var itemStack: ItemStack) {
|
||||||
|
init {
|
||||||
|
require(!itemStack.isEmpty)
|
||||||
|
}
|
||||||
var lore = itemStack.loreAccordingToNbt
|
var lore = itemStack.loreAccordingToNbt
|
||||||
var name = itemStack.displayNameAccordingToNbt
|
var name = itemStack.displayNameAccordingToNbt
|
||||||
val extraAttribs = itemStack.extraAttributes.copy()
|
val extraAttribs = itemStack.extraAttributes.copy()
|
||||||
|
|||||||
15
src/main/kotlin/features/debug/itemeditor/PromptScreen.kt
Normal file
15
src/main/kotlin/features/debug/itemeditor/PromptScreen.kt
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package moe.nea.firmament.features.debug.itemeditor
|
||||||
|
|
||||||
|
import io.github.notenoughupdates.moulconfig.gui.CloseEventListener
|
||||||
|
import io.github.notenoughupdates.moulconfig.gui.GuiComponentWrapper
|
||||||
|
import io.github.notenoughupdates.moulconfig.gui.GuiContext
|
||||||
|
import io.github.notenoughupdates.moulconfig.gui.component.CenterComponent
|
||||||
|
import io.github.notenoughupdates.moulconfig.gui.component.ColumnComponent
|
||||||
|
import io.github.notenoughupdates.moulconfig.gui.component.PanelComponent
|
||||||
|
import io.github.notenoughupdates.moulconfig.gui.component.TextComponent
|
||||||
|
import io.github.notenoughupdates.moulconfig.gui.component.TextFieldComponent
|
||||||
|
import io.github.notenoughupdates.moulconfig.observer.GetSetter
|
||||||
|
import kotlin.reflect.KMutableProperty0
|
||||||
|
import moe.nea.firmament.gui.FirmButtonComponent
|
||||||
|
import moe.nea.firmament.util.MoulConfigUtils
|
||||||
|
|
||||||
@@ -24,4 +24,4 @@ class VanillaScreenProvider : HoveredItemStackProvider {
|
|||||||
val HandledScreen<*>.focusedItemStack: ItemStack?
|
val HandledScreen<*>.focusedItemStack: ItemStack?
|
||||||
get() =
|
get() =
|
||||||
HoveredItemStackProvider.allValidInstances
|
HoveredItemStackProvider.allValidInstances
|
||||||
.firstNotNullOfOrNull { it.provideHoveredItemStack(this) }
|
.firstNotNullOfOrNull { it.provideHoveredItemStack(this)?.takeIf { !it.isEmpty } }
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import io.github.notenoughupdates.moulconfig.gui.GuiContext
|
|||||||
import io.github.notenoughupdates.moulconfig.gui.GuiImmediateContext
|
import io.github.notenoughupdates.moulconfig.gui.GuiImmediateContext
|
||||||
import io.github.notenoughupdates.moulconfig.gui.KeyboardEvent
|
import io.github.notenoughupdates.moulconfig.gui.KeyboardEvent
|
||||||
import io.github.notenoughupdates.moulconfig.gui.MouseEvent
|
import io.github.notenoughupdates.moulconfig.gui.MouseEvent
|
||||||
import io.github.notenoughupdates.moulconfig.gui.component.PanelComponent
|
|
||||||
import io.github.notenoughupdates.moulconfig.observer.GetSetter
|
import io.github.notenoughupdates.moulconfig.observer.GetSetter
|
||||||
import io.github.notenoughupdates.moulconfig.platform.ModernRenderContext
|
import io.github.notenoughupdates.moulconfig.platform.ModernRenderContext
|
||||||
import io.github.notenoughupdates.moulconfig.xml.ChildCount
|
import io.github.notenoughupdates.moulconfig.xml.ChildCount
|
||||||
@@ -21,7 +20,6 @@ import java.io.File
|
|||||||
import java.util.function.Supplier
|
import java.util.function.Supplier
|
||||||
import javax.xml.namespace.QName
|
import javax.xml.namespace.QName
|
||||||
import me.shedaniel.math.Color
|
import me.shedaniel.math.Color
|
||||||
import org.jetbrains.annotations.Unmodifiable
|
|
||||||
import org.w3c.dom.Element
|
import org.w3c.dom.Element
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
@@ -41,13 +39,15 @@ object MoulConfigUtils {
|
|||||||
fun main(args: Array<out String>) {
|
fun main(args: Array<out String>) {
|
||||||
generateXSD(File("MoulConfig.xsd"), XMLUniverse.MOULCONFIG_XML_NS)
|
generateXSD(File("MoulConfig.xsd"), XMLUniverse.MOULCONFIG_XML_NS)
|
||||||
generateXSD(File("MoulConfig.Firmament.xsd"), firmUrl)
|
generateXSD(File("MoulConfig.Firmament.xsd"), firmUrl)
|
||||||
File("wrapper.xsd").writeText("""
|
File("wrapper.xsd").writeText(
|
||||||
|
"""
|
||||||
<?xml version="1.0" encoding="UTF-8" ?>
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||||
<xs:import namespace="http://notenoughupdates.org/moulconfig" schemaLocation="MoulConfig.xsd"/>
|
<xs:import namespace="http://notenoughupdates.org/moulconfig" schemaLocation="MoulConfig.xsd"/>
|
||||||
<xs:import namespace="http://firmament.nea.moe/moulconfig" schemaLocation="MoulConfig.Firmament.xsd"/>
|
<xs:import namespace="http://firmament.nea.moe/moulconfig" schemaLocation="MoulConfig.Firmament.xsd"/>
|
||||||
</xs:schema>
|
</xs:schema>
|
||||||
""".trimIndent())
|
""".trimIndent()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val firmUrl = "http://firmament.nea.moe/moulconfig"
|
val firmUrl = "http://firmament.nea.moe/moulconfig"
|
||||||
@@ -96,9 +96,11 @@ object MoulConfigUtils {
|
|||||||
override fun createInstance(context: XMLContext<*>, element: Element): FirmHoverComponent {
|
override fun createInstance(context: XMLContext<*>, element: Element): FirmHoverComponent {
|
||||||
return FirmHoverComponent(
|
return FirmHoverComponent(
|
||||||
context.getChildFragment(element),
|
context.getChildFragment(element),
|
||||||
context.getPropertyFromAttribute(element,
|
context.getPropertyFromAttribute(
|
||||||
|
element,
|
||||||
QName("lines"),
|
QName("lines"),
|
||||||
List::class.java) as Supplier<List<String>>,
|
List::class.java
|
||||||
|
) as Supplier<List<String>>,
|
||||||
context.getPropertyFromAttribute(element, QName("delay"), Duration::class.java, 0.6.seconds),
|
context.getPropertyFromAttribute(element, QName("delay"), Duration::class.java, 0.6.seconds),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -223,16 +225,21 @@ object MoulConfigUtils {
|
|||||||
generator.dumpToFile(file)
|
generator.dumpToFile(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadScreen(name: String, bindTo: Any, parent: Screen?): Screen {
|
fun wrapScreen(guiContext: GuiContext, parent: Screen?, onClose: () -> Unit = {}): Screen {
|
||||||
return object : GuiComponentWrapper(loadGui(name, bindTo)) {
|
return object : GuiComponentWrapper(guiContext) {
|
||||||
override fun close() {
|
override fun close() {
|
||||||
if (context.onBeforeClose() == CloseEventListener.CloseAction.NO_OBJECTIONS_TO_CLOSE) {
|
if (context.onBeforeClose() == CloseEventListener.CloseAction.NO_OBJECTIONS_TO_CLOSE) {
|
||||||
client!!.setScreen(parent)
|
client!!.setScreen(parent)
|
||||||
|
onClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadScreen(name: String, bindTo: Any, parent: Screen?): Screen {
|
||||||
|
return wrapScreen(loadGui(name, bindTo), parent)
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: move this utility into moulconfig (also rework guicontext into an interface so i can make this mesh better into vanilla)
|
// TODO: move this utility into moulconfig (also rework guicontext into an interface so i can make this mesh better into vanilla)
|
||||||
fun GuiContext.adopt(element: GuiComponent) = element.foldRecursive(Unit, { comp, unit -> comp.context = this })
|
fun GuiContext.adopt(element: GuiComponent) = element.foldRecursive(Unit, { comp, unit -> comp.context = this })
|
||||||
|
|
||||||
@@ -288,12 +295,14 @@ object MoulConfigUtils {
|
|||||||
assert(drawContext?.isUntranslatedGuiDrawContext() != false)
|
assert(drawContext?.isUntranslatedGuiDrawContext() != false)
|
||||||
val context = drawContext?.let(::ModernRenderContext)
|
val context = drawContext?.let(::ModernRenderContext)
|
||||||
?: IMinecraft.instance.provideTopLevelRenderContext()
|
?: IMinecraft.instance.provideTopLevelRenderContext()
|
||||||
val immContext = GuiImmediateContext(context,
|
val immContext = GuiImmediateContext(
|
||||||
|
context,
|
||||||
0, 0, 0, 0,
|
0, 0, 0, 0,
|
||||||
mouseX, mouseY,
|
mouseX, mouseY,
|
||||||
mouseX, mouseY,
|
mouseX, mouseY,
|
||||||
mouseX.toFloat(),
|
mouseX.toFloat(),
|
||||||
mouseY.toFloat())
|
mouseY.toFloat()
|
||||||
|
)
|
||||||
return immContext
|
return immContext
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
|
|
||||||
|
|
||||||
package moe.nea.firmament.util.async
|
package moe.nea.firmament.util.async
|
||||||
|
|
||||||
|
import io.github.notenoughupdates.moulconfig.gui.GuiContext
|
||||||
|
import io.github.notenoughupdates.moulconfig.gui.component.CenterComponent
|
||||||
|
import io.github.notenoughupdates.moulconfig.gui.component.ColumnComponent
|
||||||
|
import io.github.notenoughupdates.moulconfig.gui.component.PanelComponent
|
||||||
|
import io.github.notenoughupdates.moulconfig.gui.component.TextComponent
|
||||||
|
import io.github.notenoughupdates.moulconfig.gui.component.TextFieldComponent
|
||||||
|
import io.github.notenoughupdates.moulconfig.observer.GetSetter
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
|
import net.minecraft.client.gui.screen.Screen
|
||||||
import moe.nea.firmament.events.HandledScreenKeyPressedEvent
|
import moe.nea.firmament.events.HandledScreenKeyPressedEvent
|
||||||
|
import moe.nea.firmament.gui.FirmButtonComponent
|
||||||
import moe.nea.firmament.keybindings.IKeyBinding
|
import moe.nea.firmament.keybindings.IKeyBinding
|
||||||
|
import moe.nea.firmament.util.MC
|
||||||
|
import moe.nea.firmament.util.MoulConfigUtils
|
||||||
|
import moe.nea.firmament.util.ScreenUtil
|
||||||
|
|
||||||
private object InputHandler {
|
private object InputHandler {
|
||||||
data class KeyInputContinuation(val keybind: IKeyBinding, val onContinue: () -> Unit)
|
data class KeyInputContinuation(val keybind: IKeyBinding, val onContinue: () -> Unit)
|
||||||
@@ -45,3 +55,35 @@ suspend fun waitForInput(keybind: IKeyBinding): Unit = suspendCancellableCorouti
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun createPromptScreenGuiComponent(suggestion: String, prompt: String, action: Runnable) = (run {
|
||||||
|
val text = GetSetter.floating(suggestion)
|
||||||
|
GuiContext(
|
||||||
|
CenterComponent(
|
||||||
|
PanelComponent(
|
||||||
|
ColumnComponent(
|
||||||
|
TextFieldComponent(text, 120),
|
||||||
|
FirmButtonComponent(TextComponent(prompt), action = action)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) to text
|
||||||
|
})
|
||||||
|
|
||||||
|
suspend fun waitForTextInput(suggestion: String, prompt: String) =
|
||||||
|
suspendCancellableCoroutine<String> { cont ->
|
||||||
|
lateinit var screen: Screen
|
||||||
|
lateinit var text: GetSetter<String>
|
||||||
|
val action = {
|
||||||
|
if (MC.screen === screen)
|
||||||
|
MC.screen = null
|
||||||
|
// TODO: should this exit
|
||||||
|
cont.resume(text.get())
|
||||||
|
}
|
||||||
|
val (gui, text_) = createPromptScreenGuiComponent(suggestion, prompt, action)
|
||||||
|
text = text_
|
||||||
|
screen = MoulConfigUtils.wrapScreen(gui, null, onClose = action)
|
||||||
|
ScreenUtil.setScreenLater(screen)
|
||||||
|
cont.invokeOnCancellation {
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user