Tabbed chats with BBCode rendering and clickable identities

Replace the chat target dropdown with a tab strip along the bottom of the
chat panel: a Server and a Channel tab plus one closable tab per private
chat, each with its own log and an unread marker.

Render logs and channel/client descriptions as HTML through a new BBCode
renderer in core, supporting b/i/u/s, color, size, url, center and lists,
with auto-linked bare URLs. Input is escaped and tag values validated, so
server-supplied text cannot inject markup or unsafe schemes.

Sender names and TeamSpeak's own client:// links open the same context menu
as the server tree (menus extracted into ClientMenu/ChannelMenu); channel://
links open the channel menu. Links are styled by CSS class: identities bold
in the author colour, other TS protocols plain, and links leaving the client
underlined so they cannot be mistaken for an identity.

Dragging a client or channel out of the tree yields the TS3 link BBCode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 13:50:04 +00:00
parent a69b7bde74
commit 707d9ebbba
11 changed files with 800 additions and 103 deletions

View File

@@ -143,6 +143,14 @@ public final class ServerModel {
clients.remove(id);
}
public synchronized ClientEntry findClientByUniqueId(String uniqueId) {
if (uniqueId == null || uniqueId.isEmpty()) return null;
for (ClientEntry c : clients.values()) {
if (uniqueId.equals(c.uniqueId)) return c;
}
return null;
}
public synchronized ClientEntry findClientByName(String name) {
for (ClientEntry c : clients.values()) {
if (c.nickname != null && c.nickname.equals(name)) return c;

View File

@@ -0,0 +1,208 @@
package com.ts3client.text;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Renderer for the small BBCode dialect TeamSpeak uses in chat messages and
* channel/client descriptions. Produces an HTML fragment restricted to the tags
* Swing's HTML 3.2 renderer understands; anything unrecognised is passed
* through as literal text, and all input is escaped, so server-supplied text
* can never inject markup.
*/
public final class BBCode {
/** {@code [url]http://x[/url]} is rewritten to the {@code [url=…]} form before parsing. */
private static final Pattern BARE_URL_TAG =
Pattern.compile("\\[url]([^\\[\\]]+)\\[/url]", Pattern.CASE_INSENSITIVE);
private static final Pattern BARE_IMG_TAG =
Pattern.compile("\\[img]([^\\[\\]]+)\\[/img]", Pattern.CASE_INSENSITIVE);
private static final Pattern PLAIN_URL =
Pattern.compile("(?i)\\b(?:https?://|www\\.)[^\\s<>\"']+");
private static final Pattern COLOR = Pattern.compile("#[0-9a-fA-F]{3,6}|[a-zA-Z]{3,20}");
/** Web links plus TeamSpeak's own {@code client://} / {@code channel://} / {@code ts3server://} schemes. */
private static final Pattern SAFE_URL =
Pattern.compile("(?i)(https?|ftp|client|channel|ts3server|ts3file)://[^\\s\"'<>]*");
/** TeamSpeak's own protocols, which are rendered without an underline. */
private static final Pattern TS_PROTOCOL =
Pattern.compile("(?i)^(client|channel|ts3server|ts3file)://");
private static final Pattern CLIENT_PROTOCOL = Pattern.compile("(?i)^client://");
/** CSS class on {@code client://} links, styled like a message author's name. */
public static final String IDENTITY_LINK_CLASS = "identity";
/** CSS class on links using TeamSpeak's other protocols (channel, ts3server, …). */
public static final String TS_LINK_CLASS = "tslink";
/** CSS class on links that leave the client, so they are visibly not an identity. */
public static final String EXTERNAL_LINK_CLASS = "extlink";
private BBCode() {
}
/** Converts BBCode to an HTML fragment (no surrounding {@code <html>} element). */
public static String toHtml(String input) {
if (input == null || input.isEmpty()) return "";
String src = BARE_IMG_TAG.matcher(BARE_URL_TAG.matcher(input).replaceAll("[url=$1]$1[/url]"))
.replaceAll("[url=$1]$1[/url]");
StringBuilder out = new StringBuilder();
Deque<Open> open = new ArrayDeque<>();
int i = 0;
while (i < src.length()) {
int lb = src.indexOf('[', i);
if (lb < 0) {
text(out, src.substring(i), open);
break;
}
text(out, src.substring(i, lb), open);
int rb = src.indexOf(']', lb);
if (rb < 0) {
text(out, src.substring(lb), open);
break;
}
if (!emitTag(out, src.substring(lb + 1, rb), open)) {
text(out, src.substring(lb, rb + 1), open);
}
i = rb + 1;
}
while (!open.isEmpty()) out.append(open.pop().close);
return out.toString();
}
/** Escapes plain text for HTML without interpreting any BBCode. */
public static String escape(String s) {
if (s == null) return "";
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace("\"", "&quot;");
}
/** True once the tag was recognised and written; false to render it literally. */
private static boolean emitTag(StringBuilder out, String tag, Deque<Open> open) {
if (tag.isEmpty()) return false;
String lower = tag.toLowerCase();
if (lower.charAt(0) == '/') {
return closeTag(out, lower.substring(1), open);
}
if (lower.equals("*")) {
if (!contains(open, "list")) return false;
if (peekIs(open, "*")) out.append(open.pop().close);
push(out, open, "*", "<li>", "</li>");
return true;
}
int eq = tag.indexOf('=');
String name = (eq < 0 ? lower : lower.substring(0, eq)).trim();
String value = eq < 0 ? "" : tag.substring(eq + 1).trim();
switch (name) {
case "b":
return push(out, open, name, "<b>", "</b>");
case "i":
return push(out, open, name, "<i>", "</i>");
case "u":
return push(out, open, name, "<u>", "</u>");
case "s":
return push(out, open, name, "<strike>", "</strike>");
case "center":
return push(out, open, name, "<div align=\"center\">", "</div>");
case "list":
return push(out, open, name, "<ul>", "</ul>");
case "color": {
if (!COLOR.matcher(value).matches()) return false;
return push(out, open, name, "<font color=\"" + value + "\">", "</font>");
}
case "size": {
int px = parseSize(value);
if (px < 0) return false;
return push(out, open, name, "<span style=\"font-size:" + px + "px\">", "</span>");
}
case "url": {
if (!SAFE_URL.matcher(value).matches()) return false;
return push(out, open, name, link(value), linkClose(value));
}
default:
return false;
}
}
private static boolean closeTag(StringBuilder out, String name, Deque<Open> open) {
if (!contains(open, name)) return false;
// Close anything left dangling inside, then the tag itself.
while (!open.isEmpty()) {
Open o = open.pop();
out.append(o.close);
if (o.name.equals(name)) break;
}
return true;
}
private static String link(String href) {
return "<a class=\"" + linkClass(href) + "\" href=\"" + escape(href) + "\">";
}
private static String linkClose(String href) {
return "</a>";
}
/** The CSS class a link gets, by protocol. */
public static String linkClass(String href) {
if (CLIENT_PROTOCOL.matcher(href).find()) return IDENTITY_LINK_CLASS;
return TS_PROTOCOL.matcher(href).find() ? TS_LINK_CLASS : EXTERNAL_LINK_CLASS;
}
private static boolean push(StringBuilder out, Deque<Open> open, String name,
String openHtml, String closeHtml) {
out.append(openHtml);
open.push(new Open(name, closeHtml));
return true;
}
private static boolean contains(Deque<Open> open, String name) {
for (Open o : open) {
if (o.name.equals(name)) return true;
}
return false;
}
private static boolean peekIs(Deque<Open> open, String name) {
return !open.isEmpty() && open.peek().name.equals(name);
}
private static int parseSize(String value) {
try {
return Math.max(8, Math.min(28, Integer.parseInt(value.trim())));
} catch (NumberFormatException e) {
return -1;
}
}
/** Escapes a text run, turning newlines into breaks and linkifying bare URLs. */
private static void text(StringBuilder out, String raw, Deque<Open> open) {
if (raw.isEmpty()) return;
if (contains(open, "url")) { // don't nest links inside a link label
out.append(escape(raw).replace("\n", "<br>"));
return;
}
Matcher m = PLAIN_URL.matcher(raw);
int last = 0;
while (m.find()) {
out.append(escape(raw.substring(last, m.start())).replace("\n", "<br>"));
String url = m.group();
String href = url.regionMatches(true, 0, "www.", 0, 4) ? "http://" + url : url;
out.append(link(href)).append(escape(url)).append(linkClose(href));
last = m.end();
}
out.append(escape(raw.substring(last)).replace("\n", "<br>"));
}
private static final class Open {
final String name;
final String close;
Open(String name, String close) {
this.name = name;
this.close = close;
}
}
}

View File

@@ -0,0 +1,110 @@
package com.ts3client.text;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
/**
* TeamSpeak's own link schemes, as produced when a client or channel is dragged
* into the text box:
* {@code client://<clid>/<unique id>~<nickname>} and {@code channel://<cid>/<name>}.
*/
public final class TsLink {
public static final String CLIENT_SCHEME = "client://";
public static final String CHANNEL_SCHEME = "channel://";
/** A parsed {@code client://} or {@code channel://} link. */
public static final class Ref {
public final boolean client;
public final int id;
/** Client unique id, or {@code ""} for channel links / when absent. */
public final String uniqueId;
/** Nickname or channel name, or {@code ""} when absent. */
public final String name;
Ref(boolean client, int id, String uniqueId, String name) {
this.client = client;
this.id = id;
this.uniqueId = uniqueId;
this.name = name;
}
}
private TsLink() {
}
/** Parses a TS link, or returns {@code null} if it is not one. */
public static Ref parse(String href) {
if (href == null) return null;
boolean client = startsWithIgnoreCase(href, CLIENT_SCHEME);
if (!client && !startsWithIgnoreCase(href, CHANNEL_SCHEME)) return null;
String rest = href.substring((client ? CLIENT_SCHEME : CHANNEL_SCHEME).length());
int slash = rest.indexOf('/');
String idPart = slash < 0 ? rest : rest.substring(0, slash);
String tail = slash < 0 ? "" : rest.substring(slash + 1);
int id;
try {
id = Integer.parseInt(idPart.trim());
} catch (NumberFormatException e) {
return null;
}
String uid = "";
String name = tail;
if (client) {
int tilde = tail.indexOf('~');
if (tilde >= 0) {
uid = tail.substring(0, tilde);
name = tail.substring(tilde + 1);
} else {
uid = tail;
name = "";
}
}
return new Ref(client, id, decode(uid), decode(name));
}
public static String clientHref(int clientId, String uniqueId, String nickname) {
return CLIENT_SCHEME + clientId + "/" + encode(uniqueId) + "~" + encode(nickname);
}
public static String channelHref(int channelId, String name) {
return CHANNEL_SCHEME + channelId + "/" + encode(name);
}
/** The BBCode TeamSpeak inserts when a client is dragged into the text box. */
public static String clientBBCode(int clientId, String uniqueId, String nickname) {
return "[URL=" + clientHref(clientId, uniqueId, nickname) + "]" + nickname + "[/URL]";
}
/** The BBCode TeamSpeak inserts when a channel is dragged into the text box. */
public static String channelBBCode(int channelId, String name) {
return "[URL=" + channelHref(channelId, name) + "]" + name + "[/URL]";
}
private static boolean startsWithIgnoreCase(String s, String prefix) {
return s.regionMatches(true, 0, prefix, 0, prefix.length());
}
private static String encode(String s) {
if (s == null) return "";
try {
// TS3 leaves the base64 padding of unique ids unescaped, so match that.
return URLEncoder.encode(s, "UTF-8").replace("+", "%20").replace("%3D", "=");
} catch (UnsupportedEncodingException e) {
return s;
}
}
private static String decode(String s) {
if (s == null || s.isEmpty()) return "";
try {
return URLDecoder.decode(s, "UTF-8");
} catch (UnsupportedEncodingException | IllegalArgumentException e) {
return s;
}
}
}