Merge pull request #13 from NotAlexNoyle/main
Many fixes (with the help of claude)
This commit is contained in:
16 files changed
+128
-116
No files matched your search
+5
-4
@@ -2,10 +2,11 @@
|
||||
|
||||
NotAlexNoyle 05/06/26:
|
||||
|
||||
- Drop PlaceholderAPI dependency. Resolve Unions-OG union color tags
|
||||
through MiniPlaceholders via Utilities-OG.
|
||||
- Forward more types of exceptions to the server console.
|
||||
- Tighten LuckPerms prefix/suffix spacing and trim handling.
|
||||
- Port all uses of PlaceholderAPI to MiniPlaceholders / Utilities-OG APIs.
|
||||
- Fix Unions-OG Union colors breaking MC -> Discord link.
|
||||
- Drop the per-Discord-role suffix configuration. Player suffixes shown in messages now come exclusively from each player's individual LuckPerms suffix.
|
||||
- Fix LuckPerms spacing so exactly one space appears between the player name and any prefix/suffix.
|
||||
- Configure Color Code Roles.
|
||||
|
||||
NotAlexNoyle 05/04/26:
|
||||
|
||||
|
||||
+3
-1
@@ -51,13 +51,15 @@ repositories {
|
||||
/* ---------------------- Java project deps ---------------------------- */
|
||||
dependencies {
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:1.19.4-R0.1-SNAPSHOT") // Declare Purpur API version to be packaged.
|
||||
compileOnly("net.essentialsx:EssentialsX:2.21.0") // Import EssentialsX API (internally deprecated).
|
||||
compileOnly(
|
||||
files("libs/AnnouncerPlus-1.4.1.jar")
|
||||
) // Import AnnouncerPlus API (binary-compatible with AnnouncerPlus-OG API).
|
||||
compileOnlyApi(project(":libs:Utilities-OG")) // Import TrueOG Network Utilities-OG Java API (from source).
|
||||
compileOnlyApi(project(":libs:Vanish-OG")) // Import TrueOG Network Vanish-OG Java API (from source).
|
||||
compileOnly("net.luckperms:api:5.5") // Import LuckPerms API.
|
||||
compileOnly(
|
||||
"io.github.miniplaceholders:miniplaceholders-api:2.2.3"
|
||||
) // Import MiniPlaceholders API for direct tag access.
|
||||
implementation("tools.jackson.core:jackson-databind:3.1.1")
|
||||
implementation("tools.jackson.dataformat:jackson-dataformat-yaml:3.1.1")
|
||||
|
||||
|
||||
+1
-1
Submodule libs/Utilities-OG updated: cfb722c394...3e26fb1a84.
+1
-1
Submodule libs/Vanish-OG updated: eb4f80f467...3fcdfee824.
@@ -1,6 +1,5 @@
|
||||
package nl.skbotnl.chatog
|
||||
|
||||
import com.earth2me.essentials.Essentials
|
||||
import java.util.*
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock
|
||||
import kotlin.concurrent.read
|
||||
@@ -51,7 +50,6 @@ internal class ChatOG : JavaPlugin() {
|
||||
var translator: OpenAITranslator? = null
|
||||
var discordBridge: DiscordBridge? = null
|
||||
val discordBridgeLock = ReentrantReadWriteLock()
|
||||
var essentials = Bukkit.getServer().pluginManager.getPlugin("Essentials-OG") as Essentials
|
||||
var lastMessagedMap: MutableMap<UUID, UUID> = HashMap()
|
||||
|
||||
fun isConfigInitialized() = ::config.isInitialized
|
||||
|
||||
@@ -19,8 +19,8 @@ import nl.skbotnl.chatog.ChatOG.Companion.discordBridgeLock
|
||||
import nl.skbotnl.chatog.ChatOG.Companion.scope
|
||||
import nl.skbotnl.chatog.util.ChatUtil
|
||||
import nl.skbotnl.chatog.util.ChatUtil.legacyToMm
|
||||
import nl.skbotnl.chatog.util.PlayerAffix
|
||||
import nl.skbotnl.chatog.util.PlayerExtensions.chatSystem
|
||||
import nl.skbotnl.chatog.util.PlayerUtils
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.event.EventHandler
|
||||
import org.bukkit.event.Listener
|
||||
@@ -42,7 +42,7 @@ internal class Events : Listener {
|
||||
return
|
||||
}
|
||||
|
||||
val playerPartString = ChatUtil.getPlayerPartString(event.player)
|
||||
val playerPartString = ChatUtil.getPlayerPartString(event.player, includeSuffix = true)
|
||||
|
||||
scope.launch {
|
||||
discordBridgeLock.read {
|
||||
@@ -66,7 +66,7 @@ internal class Events : Listener {
|
||||
return
|
||||
}
|
||||
|
||||
val playerPartString = ChatUtil.getPlayerPartString(event.player)
|
||||
val playerPartString = ChatUtil.getPlayerPartString(event.player, includeSuffix = true)
|
||||
|
||||
scope.launch {
|
||||
discordBridgeLock.read {
|
||||
@@ -90,7 +90,7 @@ internal class Events : Listener {
|
||||
return
|
||||
}
|
||||
|
||||
val playerPartString = ChatUtil.getPlayerPartString(event.player)
|
||||
val playerPartString = ChatUtil.getPlayerPartString(event.player, includeSuffix = true)
|
||||
|
||||
val reason = PlainTextComponentSerializer.plainText().serialize(event.reason())
|
||||
|
||||
@@ -116,7 +116,7 @@ internal class Events : Listener {
|
||||
return
|
||||
}
|
||||
|
||||
val playerPartString = ChatUtil.getPlayerPartString(event.player)
|
||||
val playerPartString = ChatUtil.getPlayerPartString(event.player, includeSuffix = true)
|
||||
|
||||
val advancementTitleKey = event.advancement.display?.title() ?: return
|
||||
val advancementTitle = PlainTextComponentSerializer.plainText().serialize(advancementTitleKey)
|
||||
@@ -188,13 +188,12 @@ internal class Events : Listener {
|
||||
return
|
||||
}
|
||||
|
||||
var nameString = "${PlayerAffix.getPrefix(event.player.uniqueId)}${event.player.name}"
|
||||
var nameString = "${PlayerUtils.getPrefix(event.player.uniqueId)}${event.player.name}"
|
||||
|
||||
val unionColorTag =
|
||||
PlainTextComponentSerializer.plainText()
|
||||
.serialize(UtilitiesOG.trueogExpand("<simpleclans_union_color_tag>", event.player))
|
||||
if (unionColorTag.isNotEmpty() && unionColorTag != "&8None") {
|
||||
nameString = "&8[$unionColorTag&8] $nameString"
|
||||
val unionTag = UtilitiesOG.trueogExpand("<simpleclans_clan_color_tag>", event.player)
|
||||
val unionPlainTag = UtilitiesOG.stripFormatting(unionTag)
|
||||
if (unionPlainTag.isNotEmpty() && unionPlainTag != "None") {
|
||||
nameString = "&8[$unionTag&8] $nameString"
|
||||
}
|
||||
val nameComponent = UtilitiesOG.trueogColorize(legacyToMm(nameString))
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import net.kyori.adventure.text.event.HoverEvent
|
||||
import net.trueog.utilitiesog.UtilitiesOG
|
||||
import nl.skbotnl.chatog.translation.command.TranslateMessage
|
||||
import nl.skbotnl.chatog.util.ChatUtil
|
||||
import nl.skbotnl.chatog.util.PlayerAffix
|
||||
import nl.skbotnl.chatog.util.PlayerUtils
|
||||
import org.bukkit.entity.Player
|
||||
|
||||
internal abstract class ChatSystem {
|
||||
@@ -23,13 +23,17 @@ internal abstract class ChatSystem {
|
||||
var playerPartString = ChatUtil.getPlayerPartString(player)
|
||||
playerPartString = listOfNotNull(prefix, playerPartString).joinToString(" | ")
|
||||
|
||||
sendDiscordMessage(text, playerPartString, player.uniqueId)
|
||||
val discordPlayerPartString =
|
||||
listOfNotNull(prefix, ChatUtil.getPlayerPartString(player, includeSuffix = true)).joinToString(" | ")
|
||||
|
||||
val messageComponent = ChatUtil.processText(text, player) ?: return
|
||||
sendDiscordMessage(text, discordPlayerPartString, player.uniqueId)
|
||||
|
||||
val messageComponent =
|
||||
ChatUtil.processText(text, player)?.color(PlayerUtils.getMessageColor(player.uniqueId)) ?: return
|
||||
|
||||
val chatComponent =
|
||||
UtilitiesOG.trueogColorize(
|
||||
ChatUtil.legacyToMm("$playerPartString<reset>${PlayerAffix.getSuffix(player.uniqueId)} &7> ")
|
||||
ChatUtil.legacyToMm("$playerPartString<reset>${PlayerUtils.getSuffix(player.uniqueId)} &7> ")
|
||||
)
|
||||
|
||||
var textComponent = Component.join(JoinConfiguration.noSeparators(), chatComponent, messageComponent)
|
||||
|
||||
@@ -23,7 +23,4 @@ constructor(
|
||||
@param:JsonProperty("useColorCodeRoles") val useColorCodeRoles: Boolean,
|
||||
@param:JsonProperty("colorCodeRoles") @field:JsonSetter(nulls = Nulls.AS_EMPTY) val colorCodeRoles: List<String>,
|
||||
@param:JsonProperty("roles") @field:JsonSetter(nulls = Nulls.AS_EMPTY) val roles: Map<String, DiscordRoleConfig>,
|
||||
@param:JsonProperty("roleSuffixes")
|
||||
@field:JsonSetter(nulls = Nulls.AS_EMPTY)
|
||||
val roleSuffixes: Map<String, DiscordSuffixConfig>,
|
||||
)
|
||||
@@ -1,6 +0,0 @@
|
||||
package nl.skbotnl.chatog.config.discord
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class DiscordSuffixConfig @JsonCreator constructor(@param:JsonProperty("suffix") val suffix: String)
|
||||
@@ -187,12 +187,7 @@ internal class DiscordBridge private constructor() {
|
||||
?: NamedTextColor.GRAY
|
||||
}
|
||||
|
||||
val suffix =
|
||||
if (config.discord.roleSuffixes.isEmpty()) {
|
||||
"> "
|
||||
} else {
|
||||
sortedRoles.firstNotNullOfOrNull { r -> config.discord.roleSuffixes[r.id] }?.suffix ?: "> "
|
||||
}
|
||||
val suffix = " > "
|
||||
|
||||
val messageComponents = mutableListOf<Component>(UtilitiesOG.trueogColorize(suffix))
|
||||
|
||||
@@ -368,7 +363,7 @@ internal class DiscordBridge private constructor() {
|
||||
message.contentDisplay,
|
||||
member.effectiveName,
|
||||
Component.join(JoinConfiguration.noSeparators(), discordComponent, userComponent),
|
||||
Component.text("$suffix "),
|
||||
Component.text(suffix),
|
||||
)
|
||||
}
|
||||
return discordBridge
|
||||
@@ -398,7 +393,7 @@ internal class DiscordBridge private constructor() {
|
||||
suspend fun sendMessage(message: String, player: Player) {
|
||||
val webhookMessage =
|
||||
WebhookMessageBuilder()
|
||||
.setUsername(UtilitiesOG.stripFormatting(ChatUtil.getPlayerPartString(player)))
|
||||
.setUsername(UtilitiesOG.stripFormatting(ChatUtil.getPlayerPartString(player, includeSuffix = true)))
|
||||
.setContent(UtilitiesOG.stripFormatting(ChatUtil.stripGroupMentions(ChatUtil.convertMentions(message))))
|
||||
.setAvatarUrl("https://minotar.net/helm/${player.uniqueId}.png")
|
||||
|
||||
|
||||
@@ -4,14 +4,13 @@ import java.util.*
|
||||
import net.kyori.adventure.text.Component
|
||||
import net.kyori.adventure.text.JoinConfiguration
|
||||
import net.kyori.adventure.text.format.NamedTextColor
|
||||
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer
|
||||
import net.trueog.utilitiesog.UtilitiesOG
|
||||
import nl.skbotnl.chatog.ChatOG.Companion.config
|
||||
import nl.skbotnl.chatog.ChatOG.Companion.languageDatabase
|
||||
import nl.skbotnl.chatog.ChatOG.Companion.translator
|
||||
import nl.skbotnl.chatog.util.ChatUtil
|
||||
import nl.skbotnl.chatog.util.ChatUtil.legacyToMm
|
||||
import nl.skbotnl.chatog.util.PlayerAffix
|
||||
import nl.skbotnl.chatog.util.PlayerUtils
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.command.Command
|
||||
import org.bukkit.command.CommandExecutor
|
||||
@@ -138,15 +137,15 @@ internal class TranslateMessage : CommandExecutor {
|
||||
when (messageType) {
|
||||
1 -> {
|
||||
val sentChatMessage = sentMessage as SentChatMessage
|
||||
// TODO: Should use ChatUtil.getPlayerPartString
|
||||
var playerString =
|
||||
"${PlayerAffix.getPrefix(sentChatMessage.player.uniqueId)}${sentChatMessage.player.name}${
|
||||
PlayerAffix.getSuffix(sentChatMessage.player.uniqueId)
|
||||
"${PlayerUtils.getPrefix(sentChatMessage.player.uniqueId)}${sentChatMessage.player.name}${
|
||||
PlayerUtils.getSuffix(sentChatMessage.player.uniqueId)
|
||||
}"
|
||||
val unionColorTag =
|
||||
PlainTextComponentSerializer.plainText()
|
||||
.serialize(UtilitiesOG.trueogExpand("<simpleclans_clan_color_tag>", sentMessage.player))
|
||||
if (unionColorTag.isNotEmpty() && unionColorTag != "&8None") {
|
||||
playerString = "&8[$unionColorTag&8] $playerString"
|
||||
val unionTag = UtilitiesOG.trueogExpand("<simpleclans_clan_color_tag>", player)
|
||||
val unionPlainTag = UtilitiesOG.stripFormatting(unionTag)
|
||||
if (unionPlainTag.isNotEmpty() && unionPlainTag != "None") {
|
||||
playerString = "&8[$unionTag&8] $playerString"
|
||||
}
|
||||
val playerComponent =
|
||||
UtilitiesOG.trueogColorize(
|
||||
@@ -155,7 +154,6 @@ internal class TranslateMessage : CommandExecutor {
|
||||
)
|
||||
)
|
||||
|
||||
// Don't convert color in translated messages
|
||||
translateMessage =
|
||||
Component.join(
|
||||
JoinConfiguration.noSeparators(),
|
||||
|
||||
@@ -25,14 +25,14 @@ import org.bukkit.Sound
|
||||
import org.bukkit.entity.Player
|
||||
|
||||
internal object ChatUtil {
|
||||
fun getPlayerPartString(player: Player): String {
|
||||
var playerPart = "${PlayerAffix.getPrefix(player.uniqueId)}${player.name}"
|
||||
fun getPlayerPartString(player: Player, includeSuffix: Boolean = false): String {
|
||||
var playerPart =
|
||||
"${PlayerUtils.getPrefix(player.uniqueId)}${player.name}${if (includeSuffix) PlayerUtils.getSuffix(player.uniqueId) else ""}"
|
||||
|
||||
val unionColorTag =
|
||||
PlainTextComponentSerializer.plainText()
|
||||
.serialize(UtilitiesOG.trueogExpand("<simpleclans_clan_color_tag>", player))
|
||||
if (unionColorTag.isNotEmpty() && unionColorTag != "&8None") {
|
||||
playerPart = legacyToMm("&8[$unionColorTag&8] ") + playerPart
|
||||
val unionTag = UtilitiesOG.trueogExpand("<simpleclans_clan_color_tag>", player)
|
||||
val unionPlainTag = UtilitiesOG.stripFormatting(unionTag)
|
||||
if (unionPlainTag.isNotEmpty() && unionPlainTag != "None") {
|
||||
playerPart = legacyToMm("&8[$unionTag&8] ") + playerPart
|
||||
}
|
||||
|
||||
return playerPart
|
||||
@@ -102,6 +102,10 @@ internal object ChatUtil {
|
||||
return legacyRegex.replace(text) { legacyToMmMap[it.groupValues[1].lowercase()] ?: it.value }
|
||||
}
|
||||
|
||||
fun stripFormatting(text: String): String {
|
||||
return MiniMessage.miniMessage().stripTags(legacyRegex.replace(text, ""))
|
||||
}
|
||||
|
||||
private val getHandle = Regex("@([a-z0-9_.]{2,32})")
|
||||
|
||||
suspend fun convertMentions(text: String): String {
|
||||
@@ -170,8 +174,10 @@ internal object ChatUtil {
|
||||
|
||||
private fun processText(text: String, player: Player?, username: String?, color: Boolean): Component? {
|
||||
val words: MutableList<String> = mutableListOf()
|
||||
val canUseFormatting = player?.hasPermission("chat-og.color") == true || color
|
||||
val textToProcess = if (canUseFormatting) legacyToMm(text) else stripFormatting(text)
|
||||
|
||||
legacyToMm(text).split(" ").forEach { word ->
|
||||
textToProcess.split(" ").forEach { word ->
|
||||
val url = urlRegex.find(word)
|
||||
|
||||
if (url != null) {
|
||||
@@ -206,14 +212,10 @@ internal object ChatUtil {
|
||||
}
|
||||
}
|
||||
|
||||
return if (player?.hasPermission("chat-og.color") == true) {
|
||||
return if (canUseFormatting) {
|
||||
colorMm.deserialize(words.joinToString(" "))
|
||||
} else {
|
||||
if (color) {
|
||||
colorMm.deserialize(words.joinToString(" "))
|
||||
} else {
|
||||
noColorMm.deserialize(words.joinToString(" "))
|
||||
}
|
||||
noColorMm.deserialize(words.joinToString(" "))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package nl.skbotnl.chatog.util
|
||||
|
||||
import java.util.*
|
||||
import nl.skbotnl.chatog.ChatOG.Companion.luckPerms
|
||||
import nl.skbotnl.chatog.util.ChatUtil.legacyToMm
|
||||
|
||||
internal object PlayerAffix {
|
||||
fun getPrefix(uuid: UUID): String {
|
||||
val prefix = getMetaData(uuid).prefix ?: return ""
|
||||
return legacyToMm(prefix).trimEnd() + " "
|
||||
}
|
||||
|
||||
fun getSuffix(uuid: UUID): String {
|
||||
val suffix = getMetaData(uuid).suffix ?: return ""
|
||||
return " " + legacyToMm(suffix).trim()
|
||||
}
|
||||
|
||||
private fun getMetaData(uuid: UUID) =
|
||||
with(luckPerms.userManager.getUser(uuid) ?: luckPerms.userManager.loadUser(uuid).join()) {
|
||||
val queryOptions = luckPerms.contextManager.getQueryOptions(this).orElse(queryOptions)
|
||||
cachedData.getMetaData(queryOptions)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package nl.skbotnl.chatog.util
|
||||
|
||||
import java.util.*
|
||||
import net.kyori.adventure.text.format.NamedTextColor
|
||||
import net.kyori.adventure.text.format.TextColor
|
||||
import nl.skbotnl.chatog.ChatOG.Companion.luckPerms
|
||||
|
||||
internal object PlayerUtils {
|
||||
fun getPrefix(uuid: UUID): String {
|
||||
val prefix = getMetaData(uuid).prefix?.trim() ?: return ""
|
||||
return if (prefix.endsWith(' ')) prefix else "$prefix "
|
||||
}
|
||||
|
||||
fun getSuffix(uuid: UUID) = getMetaData(uuid).suffix?.trim() ?: ""
|
||||
|
||||
fun getMessageColor(uuid: UUID): TextColor {
|
||||
return getMetaData(uuid).getMetaValue("chat-og.messageColor")?.lowercase()?.let {
|
||||
NamedTextColor.NAMES.value(it)
|
||||
} ?: NamedTextColor.GRAY
|
||||
}
|
||||
|
||||
private fun getMetaData(uuid: UUID) =
|
||||
with(luckPerms.userManager.getUser(uuid) ?: luckPerms.userManager.loadUser(uuid).join()) { cachedData.metaData }
|
||||
}
|
||||
@@ -64,31 +64,53 @@ discord:
|
||||
listCommandText: "There are %onlineplayers% out of maximum 200 players online."
|
||||
|
||||
# Color code roles
|
||||
# TODO: Configure these roles.
|
||||
useColorCodeRoles: false
|
||||
# Configure which roles can use color codes with Discord -> Minecraft
|
||||
# For example:
|
||||
#
|
||||
# colorCodeRoles: [1234567891274567890, 0987654321983654321]
|
||||
# When true, only Discord users with one of the colorCodeRoles below can use
|
||||
# Utilities-OG ColorCodeMap Bukkit codes (&c, &l, &*) or MiniMessage tags
|
||||
# in Discord -> Minecraft chat. All others have their input stripped of
|
||||
# formatting before it is rendered in-game.
|
||||
useColorCodeRoles: true
|
||||
|
||||
# Configure the text color (after the username (Discord -> Minecraft)) for certain roles
|
||||
# For example:
|
||||
#
|
||||
# roles:
|
||||
# 1234567891239567890:
|
||||
# messageColor: red
|
||||
#
|
||||
# message_color can be one of the following: black, dark_blue, dark_green, dark_aqua, dark_red, dark_purple, gold, gray, dark_gray, blue, green, aqua, red, light_purple, yellow, white
|
||||
#
|
||||
# You can also use RGB for the color:
|
||||
#
|
||||
# roles:
|
||||
# 1234567891234567890:
|
||||
# messageColor: [255, 255, 255]
|
||||
# Discord role IDs allowed to use color codes from Discord.
|
||||
colorCodeRoles:
|
||||
- "897300356395073546" # OG-Pro
|
||||
- "934876475285467247" # OG Master
|
||||
- "897299946926125167" # Tester (same level as OG Master)
|
||||
- "897299018407890945" # YouTube
|
||||
- "1391741516204081162" # Technical Contributor
|
||||
- "1096297706034569287" # Helper
|
||||
- "897776965849858101" # Builder
|
||||
- "916661431850639420" # MVP
|
||||
- "1080912883694719047" # Developer
|
||||
- "897297817574440970" # Moderator
|
||||
- "897296878490763344" # Admin
|
||||
|
||||
# Configure the suffix for roles (has to include the space). color codes are allowed.
|
||||
# For example:
|
||||
|
||||
# roleSuffixes:
|
||||
# 1234567891234527890:
|
||||
# suffix: ": "
|
||||
# Default Minecraft chat color applied to a Discord -> Minecraft message when
|
||||
# the sender did not type their own color code. If a user has multiple
|
||||
# configured roles the entry for their highest-positioned role wins.
|
||||
roles:
|
||||
"897657230575431710": # Member
|
||||
messageColor: gray
|
||||
"897300768619659314": # OG
|
||||
messageColor: white
|
||||
"897300356395073546": # OG-Pro
|
||||
messageColor: white
|
||||
"934876475285467247": # OG Master
|
||||
messageColor: white
|
||||
"897299946926125167": # Tester
|
||||
messageColor: white
|
||||
"897299018407890945": # YouTube
|
||||
messageColor: white
|
||||
"1391741516204081162": # Technical Contributor
|
||||
messageColor: white
|
||||
"1096297706034569287": # Helper
|
||||
messageColor: white
|
||||
"897776965849858101": # Builder
|
||||
messageColor: white
|
||||
"916661431850639420": # MVP
|
||||
messageColor: white
|
||||
"1080912883694719047": # Developer
|
||||
messageColor: white
|
||||
"897297817574440970": # Moderator
|
||||
messageColor: white
|
||||
"897296878490763344": # Admin
|
||||
messageColor: white
|
||||
@@ -7,8 +7,7 @@ depend:
|
||||
- LuckPerms
|
||||
- AnnouncerPlus
|
||||
- Utilities-OG
|
||||
softdepend:
|
||||
- Essentials-OG
|
||||
- Vanish-OG
|
||||
commands:
|
||||
translatemessage:
|
||||
description: Internal command for translating messages
|
||||
|
||||
Reference in new issue
Block a user