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()) {
|
||||
announceOwnRemoval(safeInt(e, "reasonid"), e);
|
||||
} else {
|
||||
ClientEntry leaving = conn.getModel().getClient(e.getClientId());
|
||||
String name = leaving != null ? leaving.nickname : "Client " + e.getClientId();
|
||||
String name = conn.clientLink(e.getClientId());
|
||||
announceClientLeft(e);
|
||||
logClientLeft(e, name);
|
||||
}
|
||||
@@ -79,7 +78,7 @@ final class ConnectionEventHandler implements TS3Listener {
|
||||
announceOwnMove(safeInt(e, "reasonid"), e);
|
||||
} else {
|
||||
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();
|
||||
}
|
||||
@@ -87,21 +86,22 @@ final class ConnectionEventHandler implements TS3Listener {
|
||||
|
||||
/** A client became visible to us, logged the way native TS3's server tab does. */
|
||||
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")) {
|
||||
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;
|
||||
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));
|
||||
break;
|
||||
case REASON_SWITCHED:
|
||||
conn.log(name + " switched to channel \"" + conn.channelName(e.getClientTargetId())
|
||||
+ "\", coming from channel \"" + conn.channelName(e.getClientFromId()) + "\"");
|
||||
conn.log(name + " switched to channel \"" + conn.channelLink(e.getClientTargetId())
|
||||
+ "\", coming from channel \"" + conn.channelLink(e.getClientFromId()) + "\"");
|
||||
break;
|
||||
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);
|
||||
break;
|
||||
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);
|
||||
break;
|
||||
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;
|
||||
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;
|
||||
default:
|
||||
conn.log(name + " disconnected");
|
||||
@@ -140,16 +140,16 @@ final class ConnectionEventHandler implements TS3Listener {
|
||||
String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")";
|
||||
switch (safeInt(e, "reasonid")) {
|
||||
case REASON_MOVED:
|
||||
conn.log(name + " was moved from channel \"" + conn.channelName(fromChannel) + "\" to \""
|
||||
+ conn.channelName(toChannel) + "\" by " + invokerName(e));
|
||||
conn.log(name + " was moved from channel \"" + conn.channelLink(fromChannel) + "\" to \""
|
||||
+ conn.channelLink(toChannel) + "\" by " + invokerName(e));
|
||||
break;
|
||||
case REASON_CHANNEL_KICK:
|
||||
conn.log(name + " was kicked from channel \"" + conn.channelName(fromChannel) + "\" to \""
|
||||
+ conn.channelName(toChannel) + "\" by " + invokerName(e) + suffix);
|
||||
conn.log(name + " was kicked from channel \"" + conn.channelLink(fromChannel) + "\" to \""
|
||||
+ conn.channelLink(toChannel) + "\" by " + invokerName(e) + suffix);
|
||||
break;
|
||||
default:
|
||||
conn.log(name + " switched from channel \"" + conn.channelName(fromChannel) + "\" to \""
|
||||
+ conn.channelName(toChannel) + "\"");
|
||||
conn.log(name + " switched from channel \"" + conn.channelLink(fromChannel) + "\" to \""
|
||||
+ conn.channelLink(toChannel) + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ final class ConnectionEventHandler implements TS3Listener {
|
||||
if (renamed) {
|
||||
conn.sound(safeInt(e, "invokerid") == conn.getSelfClientId()
|
||||
? 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) {
|
||||
conn.sound(SoundEvent.CLIENT_REQUESTED_TALK_POWER, vars);
|
||||
@@ -345,7 +345,7 @@ final class ConnectionEventHandler implements TS3Listener {
|
||||
if (conn.isConnected()) {
|
||||
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")));
|
||||
conn.log("Channel \"" + name + "\" was created by " + invokerName(e));
|
||||
conn.log("Channel \"" + conn.channelLink(cid) + "\" was created by " + invokerName(e));
|
||||
}
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
@@ -378,7 +378,7 @@ final class ConnectionEventHandler implements TS3Listener {
|
||||
: byInvoker(e, SoundEvent.CHANNEL_EDITED_OTHER_BY_YOU,
|
||||
SoundEvent.CHANNEL_EDITED_OTHER_BY_OTHER, SoundEvent.CHANNEL_EDITED_OTHER_BY_SERVER),
|
||||
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();
|
||||
}
|
||||
@@ -407,7 +407,7 @@ final class ConnectionEventHandler implements TS3Listener {
|
||||
if (conn.isConnected()) {
|
||||
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")));
|
||||
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();
|
||||
}
|
||||
@@ -490,10 +490,9 @@ final class ConnectionEventHandler implements TS3Listener {
|
||||
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) {
|
||||
ClientEntry c = conn.getModel().getClient(clientId);
|
||||
return c != null ? c.nickname : "Client " + clientId;
|
||||
return conn.clientLink(clientId);
|
||||
}
|
||||
|
||||
/** 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 ----
|
||||
|
||||
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"));
|
||||
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) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.ts3client.net.filetransfer.FileTransferManager;
|
||||
import com.ts3client.net.filetransfer.RemoteFile;
|
||||
import com.ts3client.sound.SoundEvent;
|
||||
import com.ts3client.sound.SoundNotifier;
|
||||
import com.ts3client.text.TsLink;
|
||||
|
||||
import java.io.File;
|
||||
import java.net.InetSocketAddress;
|
||||
@@ -1204,6 +1205,28 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
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. */
|
||||
Map<String, String> clientVars(int clientId, String fallbackName) {
|
||||
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. */
|
||||
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() {
|
||||
}
|
||||
|
||||
@@ -70,6 +74,29 @@ public final class BBCode {
|
||||
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. */
|
||||
public static String escape(String s) {
|
||||
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>"));
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
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) {
|
||||
@@ -318,6 +322,10 @@ public final class ChatPanel extends JPanel {
|
||||
// Client references look exactly like a message author's name.
|
||||
css.addRule("a." + BBCode.IDENTITY_LINK_CLASS
|
||||
+ " { 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.
|
||||
css.addRule("a." + BBCode.EXTERNAL_LINK_CLASS
|
||||
+ " { color:" + hex(Theme.LINK) + "; text-decoration:underline; }");
|
||||
|
||||
Reference in New Issue
Block a user