Link the clients and channels named in server log lines
Log lines now name clients and channels with TeamSpeak's own client:// and channel:// links, the same ones a dragged-in reference produces, so the chat log's existing handling turns them into the context menus. The log is rendered with a links-only pass rather than the full BBCode renderer: its lines are assembled by the client itself, so nothing else in them should be interpreted — a channel called "[b]VIP" stays literal. Channels we no longer have (a deleted one) are still named in plain text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -59,8 +59,7 @@ final class ConnectionEventHandler implements TS3Listener {
|
|||||||
if (e.getClientId() == conn.getSelfClientId()) {
|
if (e.getClientId() == conn.getSelfClientId()) {
|
||||||
announceOwnRemoval(safeInt(e, "reasonid"), e);
|
announceOwnRemoval(safeInt(e, "reasonid"), e);
|
||||||
} else {
|
} else {
|
||||||
ClientEntry leaving = conn.getModel().getClient(e.getClientId());
|
String name = conn.clientLink(e.getClientId());
|
||||||
String name = leaving != null ? leaving.nickname : "Client " + e.getClientId();
|
|
||||||
announceClientLeft(e);
|
announceClientLeft(e);
|
||||||
logClientLeft(e, name);
|
logClientLeft(e, name);
|
||||||
}
|
}
|
||||||
@@ -79,7 +78,7 @@ final class ConnectionEventHandler implements TS3Listener {
|
|||||||
announceOwnMove(safeInt(e, "reasonid"), e);
|
announceOwnMove(safeInt(e, "reasonid"), e);
|
||||||
} else {
|
} else {
|
||||||
announceClientMoved(safeInt(e, "reasonid"), e.getClientId(), from, e.getTargetChannelId());
|
announceClientMoved(safeInt(e, "reasonid"), e.getClientId(), from, e.getTargetChannelId());
|
||||||
logClientMoved(e, c.nickname, from, e.getTargetChannelId());
|
logClientMoved(e, conn.clientLink(c.id), from, e.getTargetChannelId());
|
||||||
}
|
}
|
||||||
conn.ui.onModelChanged();
|
conn.ui.onModelChanged();
|
||||||
}
|
}
|
||||||
@@ -87,21 +86,22 @@ final class ConnectionEventHandler implements TS3Listener {
|
|||||||
|
|
||||||
/** A client became visible to us, logged the way native TS3's server tab does. */
|
/** A client became visible to us, logged the way native TS3's server tab does. */
|
||||||
private void logClientEntered(ClientJoinEvent e) {
|
private void logClientEntered(ClientJoinEvent e) {
|
||||||
String name = e.getClientNickname();
|
String name = conn.clientLink(e.getClientId(),
|
||||||
|
TeamspeakConnection.orEmpty(e.getUniqueClientIdentifier()), e.getClientNickname());
|
||||||
switch (safeInt(e, "reasonid")) {
|
switch (safeInt(e, "reasonid")) {
|
||||||
case REASON_MOVED:
|
case REASON_MOVED:
|
||||||
conn.log(name + " appears, coming from channel \"" + conn.channelName(e.getClientFromId()) + "\"");
|
conn.log(name + " appears, coming from channel \"" + conn.channelLink(e.getClientFromId()) + "\"");
|
||||||
break;
|
break;
|
||||||
case REASON_CHANNEL_KICK:
|
case REASON_CHANNEL_KICK:
|
||||||
conn.log(name + " appears, was kicked from channel \"" + conn.channelName(e.getClientFromId())
|
conn.log(name + " appears, was kicked from channel \"" + conn.channelLink(e.getClientFromId())
|
||||||
+ "\" by " + invokerName(e));
|
+ "\" by " + invokerName(e));
|
||||||
break;
|
break;
|
||||||
case REASON_SWITCHED:
|
case REASON_SWITCHED:
|
||||||
conn.log(name + " switched to channel \"" + conn.channelName(e.getClientTargetId())
|
conn.log(name + " switched to channel \"" + conn.channelLink(e.getClientTargetId())
|
||||||
+ "\", coming from channel \"" + conn.channelName(e.getClientFromId()) + "\"");
|
+ "\", coming from channel \"" + conn.channelLink(e.getClientFromId()) + "\"");
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
conn.log(name + " connected to channel \"" + conn.channelName(e.getClientTargetId()) + "\"");
|
conn.log(name + " connected to channel \"" + conn.channelLink(e.getClientTargetId()) + "\"");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,14 +120,14 @@ final class ConnectionEventHandler implements TS3Listener {
|
|||||||
conn.log(name + " was banned from the server by " + invokerName(e) + suffix);
|
conn.log(name + " was banned from the server by " + invokerName(e) + suffix);
|
||||||
break;
|
break;
|
||||||
case REASON_CHANNEL_KICK:
|
case REASON_CHANNEL_KICK:
|
||||||
conn.log(name + " left: was kicked to channel \"" + conn.channelName(e.getClientTargetId())
|
conn.log(name + " left: was kicked to channel \"" + conn.channelLink(e.getClientTargetId())
|
||||||
+ "\" by " + invokerName(e) + suffix);
|
+ "\" by " + invokerName(e) + suffix);
|
||||||
break;
|
break;
|
||||||
case REASON_MOVED:
|
case REASON_MOVED:
|
||||||
conn.log(name + " left, heading to channel \"" + conn.channelName(e.getClientTargetId()) + "\"");
|
conn.log(name + " left, heading to channel \"" + conn.channelLink(e.getClientTargetId()) + "\"");
|
||||||
break;
|
break;
|
||||||
case REASON_SWITCHED:
|
case REASON_SWITCHED:
|
||||||
conn.log(name + " left, switched to channel \"" + conn.channelName(e.getClientTargetId()) + "\"");
|
conn.log(name + " left, switched to channel \"" + conn.channelLink(e.getClientTargetId()) + "\"");
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
conn.log(name + " disconnected");
|
conn.log(name + " disconnected");
|
||||||
@@ -140,16 +140,16 @@ final class ConnectionEventHandler implements TS3Listener {
|
|||||||
String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")";
|
String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")";
|
||||||
switch (safeInt(e, "reasonid")) {
|
switch (safeInt(e, "reasonid")) {
|
||||||
case REASON_MOVED:
|
case REASON_MOVED:
|
||||||
conn.log(name + " was moved from channel \"" + conn.channelName(fromChannel) + "\" to \""
|
conn.log(name + " was moved from channel \"" + conn.channelLink(fromChannel) + "\" to \""
|
||||||
+ conn.channelName(toChannel) + "\" by " + invokerName(e));
|
+ conn.channelLink(toChannel) + "\" by " + invokerName(e));
|
||||||
break;
|
break;
|
||||||
case REASON_CHANNEL_KICK:
|
case REASON_CHANNEL_KICK:
|
||||||
conn.log(name + " was kicked from channel \"" + conn.channelName(fromChannel) + "\" to \""
|
conn.log(name + " was kicked from channel \"" + conn.channelLink(fromChannel) + "\" to \""
|
||||||
+ conn.channelName(toChannel) + "\" by " + invokerName(e) + suffix);
|
+ conn.channelLink(toChannel) + "\" by " + invokerName(e) + suffix);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
conn.log(name + " switched from channel \"" + conn.channelName(fromChannel) + "\" to \""
|
conn.log(name + " switched from channel \"" + conn.channelLink(fromChannel) + "\" to \""
|
||||||
+ conn.channelName(toChannel) + "\"");
|
+ conn.channelLink(toChannel) + "\"");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ final class ConnectionEventHandler implements TS3Listener {
|
|||||||
if (renamed) {
|
if (renamed) {
|
||||||
conn.sound(safeInt(e, "invokerid") == conn.getSelfClientId()
|
conn.sound(safeInt(e, "invokerid") == conn.getSelfClientId()
|
||||||
? SoundEvent.CLIENT_RENAMED_BY_YOU : SoundEvent.CLIENT_RENAMED_BY_OTHER, vars);
|
? SoundEvent.CLIENT_RENAMED_BY_YOU : SoundEvent.CLIENT_RENAMED_BY_OTHER, vars);
|
||||||
conn.log(oldName + " is now known as " + c.nickname);
|
conn.log(oldName + " is now known as " + conn.clientLink(c.id));
|
||||||
}
|
}
|
||||||
if (safeInt(e, "client_talk_request") > 0 && !self) {
|
if (safeInt(e, "client_talk_request") > 0 && !self) {
|
||||||
conn.sound(SoundEvent.CLIENT_REQUESTED_TALK_POWER, vars);
|
conn.sound(SoundEvent.CLIENT_REQUESTED_TALK_POWER, vars);
|
||||||
@@ -345,7 +345,7 @@ final class ConnectionEventHandler implements TS3Listener {
|
|||||||
if (conn.isConnected()) {
|
if (conn.isConnected()) {
|
||||||
conn.sound(byInvoker(e, SoundEvent.CHANNEL_CREATED_BY_YOU, SoundEvent.CHANNEL_CREATED_BY_OTHER,
|
conn.sound(byInvoker(e, SoundEvent.CHANNEL_CREATED_BY_YOU, SoundEvent.CHANNEL_CREATED_BY_OTHER,
|
||||||
SoundEvent.CHANNEL_CREATED_BY_OTHER), conn.channelVars(cid, e.get("invokername")));
|
SoundEvent.CHANNEL_CREATED_BY_OTHER), conn.channelVars(cid, e.get("invokername")));
|
||||||
conn.log("Channel \"" + name + "\" was created by " + invokerName(e));
|
conn.log("Channel \"" + conn.channelLink(cid) + "\" was created by " + invokerName(e));
|
||||||
}
|
}
|
||||||
conn.ui.onModelChanged();
|
conn.ui.onModelChanged();
|
||||||
}
|
}
|
||||||
@@ -378,7 +378,7 @@ final class ConnectionEventHandler implements TS3Listener {
|
|||||||
: byInvoker(e, SoundEvent.CHANNEL_EDITED_OTHER_BY_YOU,
|
: byInvoker(e, SoundEvent.CHANNEL_EDITED_OTHER_BY_YOU,
|
||||||
SoundEvent.CHANNEL_EDITED_OTHER_BY_OTHER, SoundEvent.CHANNEL_EDITED_OTHER_BY_SERVER),
|
SoundEvent.CHANNEL_EDITED_OTHER_BY_OTHER, SoundEvent.CHANNEL_EDITED_OTHER_BY_SERVER),
|
||||||
conn.channelVars(ch.id, e.get("invokername")));
|
conn.channelVars(ch.id, e.get("invokername")));
|
||||||
conn.log("Channel \"" + ch.name + "\" was edited by " + invokerName(e));
|
conn.log("Channel \"" + conn.channelLink(ch.id) + "\" was edited by " + invokerName(e));
|
||||||
}
|
}
|
||||||
conn.ui.onModelChanged();
|
conn.ui.onModelChanged();
|
||||||
}
|
}
|
||||||
@@ -407,7 +407,7 @@ final class ConnectionEventHandler implements TS3Listener {
|
|||||||
if (conn.isConnected()) {
|
if (conn.isConnected()) {
|
||||||
conn.sound(byInvoker(e, SoundEvent.CHANNEL_MOVED_BY_YOU, SoundEvent.CHANNEL_MOVED_BY_OTHER,
|
conn.sound(byInvoker(e, SoundEvent.CHANNEL_MOVED_BY_YOU, SoundEvent.CHANNEL_MOVED_BY_OTHER,
|
||||||
SoundEvent.CHANNEL_MOVED_BY_OTHER), conn.channelVars(ch.id, e.get("invokername")));
|
SoundEvent.CHANNEL_MOVED_BY_OTHER), conn.channelVars(ch.id, e.get("invokername")));
|
||||||
conn.log("Channel \"" + ch.name + "\" was moved by " + invokerName(e));
|
conn.log("Channel \"" + conn.channelLink(ch.id) + "\" was moved by " + invokerName(e));
|
||||||
}
|
}
|
||||||
conn.ui.onModelChanged();
|
conn.ui.onModelChanged();
|
||||||
}
|
}
|
||||||
@@ -490,10 +490,9 @@ final class ConnectionEventHandler implements TS3Listener {
|
|||||||
conn.ui.onModelChanged();
|
conn.ui.onModelChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A client's nickname for a log line, falling back to its id once it has left. */
|
/** A client's name for a log line, as a link, falling back to its id once it has left. */
|
||||||
private String clientLogName(int clientId) {
|
private String clientLogName(int clientId) {
|
||||||
ClientEntry c = conn.getModel().getClient(clientId);
|
return conn.clientLink(clientId);
|
||||||
return c != null ? c.nickname : "Client " + clientId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Picks the event variant matching who caused the change: us, another client, or the server. */
|
/** Picks the event variant matching who caused the change: us, another client, or the server. */
|
||||||
@@ -585,9 +584,12 @@ final class ConnectionEventHandler implements TS3Listener {
|
|||||||
|
|
||||||
// ---- helpers ----
|
// ---- helpers ----
|
||||||
|
|
||||||
private static String invokerName(BaseEvent e) {
|
/** Who caused an event, linked where the server named them; "the server" when nobody did. */
|
||||||
|
private String invokerName(BaseEvent e) {
|
||||||
String name = TeamspeakConnection.orEmpty(e.get("invokername"));
|
String name = TeamspeakConnection.orEmpty(e.get("invokername"));
|
||||||
return name.isEmpty() ? "the server" : name;
|
if (name.isEmpty()) return "the server";
|
||||||
|
return conn.clientLink(safeInt(e, "invokerid"),
|
||||||
|
TeamspeakConnection.orEmpty(e.get("invokeruid")), name);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int safeInt(BaseEvent e, String key) {
|
private static int safeInt(BaseEvent e, String key) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import com.ts3client.net.filetransfer.FileTransferManager;
|
|||||||
import com.ts3client.net.filetransfer.RemoteFile;
|
import com.ts3client.net.filetransfer.RemoteFile;
|
||||||
import com.ts3client.sound.SoundEvent;
|
import com.ts3client.sound.SoundEvent;
|
||||||
import com.ts3client.sound.SoundNotifier;
|
import com.ts3client.sound.SoundNotifier;
|
||||||
|
import com.ts3client.text.TsLink;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
@@ -1204,6 +1205,28 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
return ch != null ? ch.name : "channel #" + channelId;
|
return ch != null ? ch.name : "channel #" + channelId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A channel's name as the log wants it: TeamSpeak's own {@code channel://} link, which
|
||||||
|
* the chat log turns into the same clickable entry a dragged-in channel produces. A
|
||||||
|
* channel we no longer know (a deleted one, say) is named in plain text instead.
|
||||||
|
*/
|
||||||
|
String channelLink(int channelId) {
|
||||||
|
ChannelNode ch = model.getChannel(channelId);
|
||||||
|
return ch != null ? TsLink.channelBBCode(channelId, ch.name) : channelName(channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same for a client, named by the model. */
|
||||||
|
String clientLink(int clientId) {
|
||||||
|
ClientEntry c = model.getClient(clientId);
|
||||||
|
return c != null ? clientLink(clientId, c.uniqueId, c.nickname) : "Client " + clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same for a client an event describes, which may already be gone from the model. */
|
||||||
|
String clientLink(int clientId, String uniqueId, String nickname) {
|
||||||
|
if (clientId <= 0 || nickname == null || nickname.isEmpty()) return orEmpty(nickname);
|
||||||
|
return TsLink.clientBBCode(clientId, orEmpty(uniqueId), nickname);
|
||||||
|
}
|
||||||
|
|
||||||
/** The placeholder values a pack may reference for an action involving a client. */
|
/** The placeholder values a pack may reference for an action involving a client. */
|
||||||
Map<String, String> clientVars(int clientId, String fallbackName) {
|
Map<String, String> clientVars(int clientId, String fallbackName) {
|
||||||
ClientEntry c = model.getClient(clientId);
|
ClientEntry c = model.getClient(clientId);
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ public final class BBCode {
|
|||||||
/** CSS class on links that leave the client, so they are visibly not an identity. */
|
/** CSS class on links that leave the client, so they are visibly not an identity. */
|
||||||
public static final String EXTERNAL_LINK_CLASS = "extlink";
|
public static final String EXTERNAL_LINK_CLASS = "extlink";
|
||||||
|
|
||||||
|
/** A single {@code [url=href]label[/url]} pair, the only markup the log carries. */
|
||||||
|
private static final Pattern URL_LINK =
|
||||||
|
Pattern.compile("(?i)\\[url=([^\\]]+)](.*?)\\[/url]");
|
||||||
|
|
||||||
private BBCode() {
|
private BBCode() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +74,29 @@ public final class BBCode {
|
|||||||
return out.toString();
|
return out.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders text carrying {@code [url=…]…[/url]} links and nothing else: everything
|
||||||
|
* outside those tags stays literal. The server log is written this way, so that a
|
||||||
|
* name happening to contain bracket markup is shown as it is rather than rendered.
|
||||||
|
*/
|
||||||
|
public static String linksToHtml(String input) {
|
||||||
|
if (input == null || input.isEmpty()) return "";
|
||||||
|
StringBuilder out = new StringBuilder();
|
||||||
|
Matcher m = URL_LINK.matcher(input);
|
||||||
|
int last = 0;
|
||||||
|
while (m.find()) {
|
||||||
|
out.append(escape(input.substring(last, m.start())));
|
||||||
|
String href = m.group(1);
|
||||||
|
if (SAFE_URL.matcher(href).matches()) {
|
||||||
|
out.append(link(href)).append(escape(m.group(2))).append(linkClose(href));
|
||||||
|
} else {
|
||||||
|
out.append(escape(m.group()));
|
||||||
|
}
|
||||||
|
last = m.end();
|
||||||
|
}
|
||||||
|
return out.append(escape(input.substring(last))).toString();
|
||||||
|
}
|
||||||
|
|
||||||
/** Escapes plain text for HTML without interpreting any BBCode. */
|
/** Escapes plain text for HTML without interpreting any BBCode. */
|
||||||
public static String escape(String s) {
|
public static String escape(String s) {
|
||||||
if (s == null) return "";
|
if (s == null) return "";
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.ts3client.text;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server log's renderer: it turns the client's own links into anchors and leaves
|
||||||
|
* everything else — names carrying bracket markup included — as plain text.
|
||||||
|
*/
|
||||||
|
class BBCodeLinksTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rendersClientAndChannelLinks() {
|
||||||
|
String log = TsLink.clientBBCode(7, "uid=", "Bob") + " connected to channel \""
|
||||||
|
+ TsLink.channelBBCode(3, "Lobby") + "\"";
|
||||||
|
String html = BBCode.linksToHtml(log);
|
||||||
|
assertTrue(html.contains("href=\"client://7/uid=~Bob\""), html);
|
||||||
|
assertTrue(html.contains(">Bob</a>"), html);
|
||||||
|
assertTrue(html.contains("href=\"channel://3/Lobby\""), html);
|
||||||
|
assertTrue(html.contains(">Lobby</a>"), html);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void leavesOtherMarkupLiteral() {
|
||||||
|
assertEquals("[b]not bold[/b] & <i>", BBCode.linksToHtml("[b]not bold[/b] & <i>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsBracketsInsideALabelLiteral() {
|
||||||
|
String html = BBCode.linksToHtml(TsLink.channelBBCode(3, "[cspacer]---"));
|
||||||
|
assertTrue(html.contains(">[cspacer]---</a>"), html);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsHrefsThatAreNotLinks() {
|
||||||
|
assertEquals("[url=javascript:alert(1)]x[/url]", BBCode.linksToHtml("[url=javascript:alert(1)]x[/url]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -190,10 +190,14 @@ public final class ChatPanel extends JPanel {
|
|||||||
+ BBCode.escape(text) + "</span>"));
|
+ BBCode.escape(text) + "</span>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Server-side events (client joins/leaves/moves, group changes, channel edits, …). */
|
/**
|
||||||
|
* Server-side events (client joins/leaves/moves, group changes, channel edits, …).
|
||||||
|
* These lines name clients and channels with TeamSpeak's own links, which stay
|
||||||
|
* clickable here — everything else in them is literal text.
|
||||||
|
*/
|
||||||
public void appendServerLog(String text) {
|
public void appendServerLog(String text) {
|
||||||
edt(() -> serverTab.appendLine("<span style=\"color:" + hex(Theme.CHANNEL_TEXT) + "\">" + stamp()
|
edt(() -> serverTab.appendLine("<span style=\"color:" + hex(Theme.CHANNEL_TEXT) + "\">" + stamp()
|
||||||
+ BBCode.escape(text) + "</span>"));
|
+ BBCode.linksToHtml(text) + "</span>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void appendServerMessage(int fromId, String from, String text) {
|
public void appendServerMessage(int fromId, String from, String text) {
|
||||||
@@ -318,6 +322,10 @@ public final class ChatPanel extends JPanel {
|
|||||||
// Client references look exactly like a message author's name.
|
// Client references look exactly like a message author's name.
|
||||||
css.addRule("a." + BBCode.IDENTITY_LINK_CLASS
|
css.addRule("a." + BBCode.IDENTITY_LINK_CLASS
|
||||||
+ " { color:" + hex(Theme.CHAT_NAME) + "; font-weight:bold; text-decoration:none; }");
|
+ " { color:" + hex(Theme.CHAT_NAME) + "; font-weight:bold; text-decoration:none; }");
|
||||||
|
// Channel (and other TeamSpeak protocol) references, which the log leans on
|
||||||
|
// heavily: same weight as the text around them, but their own colour.
|
||||||
|
css.addRule("a." + BBCode.TS_LINK_CLASS
|
||||||
|
+ " { color:" + hex(Theme.CHAT_NAME) + "; text-decoration:none; }");
|
||||||
// External links are underlined so they can't be mistaken for an identity.
|
// External links are underlined so they can't be mistaken for an identity.
|
||||||
css.addRule("a." + BBCode.EXTERNAL_LINK_CLASS
|
css.addRule("a." + BBCode.EXTERNAL_LINK_CLASS
|
||||||
+ " { color:" + hex(Theme.LINK) + "; text-decoration:underline; }");
|
+ " { color:" + hex(Theme.LINK) + "; text-decoration:underline; }");
|
||||||
|
|||||||
Reference in New Issue
Block a user