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:
@@ -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;
|
||||
|
||||
208
ts3-client/core/src/main/java/com/ts3client/text/BBCode.java
Normal file
208
ts3-client/core/src/main/java/com/ts3client/text/BBCode.java
Normal 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("&", "&").replace("<", "<").replace(">", ">")
|
||||
.replace("\"", """);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
110
ts3-client/core/src/main/java/com/ts3client/text/TsLink.java
Normal file
110
ts3-client/core/src/main/java/com/ts3client/text/TsLink.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user