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;
}
}
}

View File

@@ -0,0 +1,28 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import javax.swing.JMenuItem;
import javax.swing.JPopupMenu;
/**
* The context menu for a channel, shared by the server tree and channel links
* in the chat log.
*/
final class ChannelMenu {
private ChannelMenu() {
}
static JPopupMenu build(ChannelNode channel, ServerTreePanel.Actions actions) {
JPopupMenu menu = new JPopupMenu();
JMenuItem join = new JMenuItem("Join channel");
join.addActionListener(a -> actions.joinChannel(channel.id));
menu.add(join);
menu.addSeparator();
JMenuItem files = new JMenuItem("Browse files");
files.addActionListener(a -> actions.browseFiles(channel));
menu.add(files);
return menu;
}
}

View File

@@ -1,65 +1,93 @@
package com.ts3client.ui;
import com.ts3client.text.BBCode;
import com.ts3client.text.TsLink;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JEditorPane;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTabbedPane;
import javax.swing.JTextField;
import javax.swing.JTextPane;
import javax.swing.SwingUtilities;
import javax.swing.event.HyperlinkEvent;
import javax.swing.text.BadLocationException;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyledDocument;
import javax.swing.text.html.HTMLDocument;
import javax.swing.text.html.HTMLEditorKit;
import javax.swing.text.html.StyleSheet;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Cursor;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Point;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Chat log with an input line and a target selector (current channel or whole
* server). Coloured styling mimics the TS3 chat pane.
* Chat area with one tab per conversation (server, current channel and one per
* private chat). The tab strip sits between the log and the input line. Logs are
* rendered as HTML: sender names are links that open the client context menu,
* and message bodies go through the {@link BBCode} renderer.
*/
public final class ChatPanel extends JPanel {
/** Where an outgoing message should go. */
public enum Target {CHANNEL, SERVER}
public enum Target {CHANNEL, SERVER, PRIVATE}
public interface SendHandler {
void send(Target target, String text);
/** {@code clientId} is only meaningful for {@link Target#PRIVATE}. */
void send(Target target, int clientId, String text);
}
private final JTextPane log = new JTextPane();
/** Invoked when a client or channel link in the log is clicked. */
public interface LinkHandler {
void onClientLink(TsLink.Ref ref, Component source, int x, int y);
void onChannelLink(TsLink.Ref ref, Component source, int x, int y);
}
private final JTabbedPane tabs = new JTabbedPane(JTabbedPane.BOTTOM);
private final JTextField input = new JTextField();
private final JComboBox<String> targetBox = new JComboBox<>(new String[]{"Channel", "Server"});
private final SimpleDateFormat time = new SimpleDateFormat("HH:mm:ss");
private final Tab serverTab = new Tab(Target.SERVER, 0, "Server");
private final Tab channelTab = new Tab(Target.CHANNEL, 0, "Channel");
/** Private chats keyed by the peer's client id. */
private final Map<Integer, Tab> privateTabs = new LinkedHashMap<>();
private SendHandler sendHandler;
private LinkHandler linkHandler;
public ChatPanel() {
super(new BorderLayout());
log.setEditable(false);
log.setBackground(Theme.CHAT_BG);
log.setFont(Theme.UI_FONT);
log.setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
JScrollPane scroll = new JScrollPane(log);
scroll.setBorder(BorderFactory.createLineBorder(new Color(0xD0D0D0)));
add(scroll, BorderLayout.CENTER);
tabs.setFont(Theme.UI_FONT);
addTab(serverTab, false);
addTab(channelTab, false);
tabs.setSelectedIndex(0);
tabs.addChangeListener(e -> {
Tab t = selectedTab();
if (t != null) t.setUnread(false);
});
add(tabs, BorderLayout.CENTER);
JPanel bottom = new JPanel(new BorderLayout(4, 0));
bottom.setBorder(BorderFactory.createEmptyBorder(4, 0, 0, 0));
targetBox.setPreferredSize(new Dimension(90, 24));
bottom.add(targetBox, BorderLayout.WEST);
bottom.add(input, BorderLayout.CENTER);
JButton send = new JButton("Send");
bottom.add(send, BorderLayout.EAST);
add(bottom, BorderLayout.SOUTH);
Runnable doSend = this::fireSend;
send.addActionListener(e -> doSend.run());
input.addActionListener(e -> doSend.run());
send.addActionListener(e -> fireSend());
input.addActionListener(e -> fireSend());
setInputEnabled(false);
}
@@ -68,31 +96,102 @@ public final class ChatPanel extends JPanel {
this.sendHandler = h;
}
public void setLinkHandler(LinkHandler h) {
this.linkHandler = h;
}
/** Appends text to the input line, e.g. a client link dropped onto the chat. */
public void appendToInput(String text) {
edt(() -> {
String cur = input.getText();
input.setText(cur.isEmpty() || cur.endsWith(" ") ? cur + text : cur + " " + text);
input.requestFocusInWindow();
});
}
public void setInputEnabled(boolean enabled) {
input.setEnabled(enabled);
targetBox.setEnabled(enabled);
}
/** Opens (or focuses) the private chat with {@code clientId}. */
public void openPrivateChat(int clientId, String nickname) {
edt(() -> {
Tab tab = privateTab(clientId, nickname);
tabs.setSelectedComponent(tab.scroll);
input.requestFocusInWindow();
});
}
/** Drops every private chat tab, e.g. after disconnecting. */
public void closePrivateChats() {
edt(() -> {
for (Tab t : privateTabs.values()) tabs.remove(t.scroll);
privateTabs.clear();
});
}
private void fireSend() {
String text = input.getText().trim();
if (text.isEmpty() || sendHandler == null) return;
Target t = targetBox.getSelectedIndex() == 1 ? Target.SERVER : Target.CHANNEL;
sendHandler.send(t, text);
Tab tab = selectedTab();
if (tab == null) return;
sendHandler.send(tab.target, tab.clientId, text);
input.setText("");
}
// ---- append helpers (safe from any thread) ----
/** System notices always land in the server tab. */
public void appendSystem(String text) {
edt(() -> append("[" + time.format(new Date()) + "] ", Theme.CHAT_SYSTEM, false,
text, Theme.CHAT_SYSTEM, false));
edt(() -> serverTab.appendLine("<span style=\"color:#8A8A8A\">" + stamp()
+ BBCode.escape(text) + "</span>"));
}
public void appendMessage(String from, String text) {
edt(() -> {
append("[" + time.format(new Date()) + "] ", Theme.CHAT_SYSTEM, false, "", Theme.CHAT_SYSTEM, false);
append(from + ": ", Theme.CHAT_NAME, true, text, Theme.CHAT_TEXT, false);
});
public void appendServerMessage(int fromId, String from, String text) {
edt(() -> serverTab.appendMessage(fromId, from, text));
}
public void appendChannelMessage(int fromId, String from, String text) {
edt(() -> channelTab.appendMessage(fromId, from, text));
}
/** Appends to the private chat with {@code peerId}, creating the tab if needed. */
public void appendPrivateMessage(int peerId, String peerName, int fromId, String from, String text) {
edt(() -> privateTab(peerId, peerName).appendMessage(fromId, from, text));
}
private String stamp() {
return "[" + time.format(new Date()) + "] ";
}
private Tab privateTab(int clientId, String nickname) {
Tab tab = privateTabs.get(clientId);
if (tab == null) {
tab = new Tab(Target.PRIVATE, clientId, nickname);
privateTabs.put(clientId, tab);
addTab(tab, true);
}
return tab;
}
private void addTab(Tab tab, boolean closable) {
tabs.addTab(tab.title, tab.scroll);
tabs.setTabComponentAt(tabs.indexOfComponent(tab.scroll), tab.header(closable));
}
private void closeTab(Tab tab) {
privateTabs.remove(tab.clientId);
tabs.remove(tab.scroll);
}
private Tab selectedTab() {
Component c = tabs.getSelectedComponent();
if (c == serverTab.scroll) return serverTab;
if (c == channelTab.scroll) return channelTab;
for (Tab t : privateTabs.values()) {
if (t.scroll == c) return t;
}
return null;
}
private void edt(Runnable r) {
@@ -100,28 +199,121 @@ public final class ChatPanel extends JPanel {
else SwingUtilities.invokeLater(r);
}
private void append(String prefix, Color prefixColor, boolean prefixBold,
String body, Color bodyColor, boolean bodyBold) {
StyledDocument doc = log.getStyledDocument();
/** One conversation: its HTML log, its scroll pane and the tab-strip header. */
private final class Tab {
final Target target;
final int clientId;
final String title;
final JEditorPane log = new JEditorPane();
final JScrollPane scroll;
private final HTMLDocument doc;
private JLabel titleLabel;
Tab(Target target, int clientId, String title) {
this.target = target;
this.clientId = clientId;
this.title = title;
HTMLEditorKit kit = new HTMLEditorKit();
StyleSheet css = new StyleSheet();
css.addStyleSheet(kit.getStyleSheet());
css.addRule("body { font-family:sans-serif; font-size:11px; color:#202020; margin:4px 6px; }");
css.addRule("a { color:" + hex(Theme.CHAT_NAME) + "; text-decoration:none; }");
// 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; }");
// 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; }");
kit.setStyleSheet(css);
log.setEditorKit(kit);
log.setEditable(false);
log.setBackground(Theme.CHAT_BG);
log.setText("<html><body><div id=\"chatlog\"></div></body></html>");
doc = (HTMLDocument) log.getDocument();
log.addHyperlinkListener(e -> {
if (e.getEventType() != HyperlinkEvent.EventType.ACTIVATED) return;
onLink(e.getDescription());
});
scroll = new JScrollPane(log);
scroll.setBorder(BorderFactory.createEmptyBorder());
}
private void onLink(String href) {
if (href == null) return;
TsLink.Ref ref = TsLink.parse(href);
if (ref == null) {
Links.open(href, ChatPanel.this);
return;
}
if (linkHandler == null) return;
Point p = log.getMousePosition();
if (p == null) p = new Point(0, 0);
if (ref.client) linkHandler.onClientLink(ref, log, p.x, p.y);
else linkHandler.onChannelLink(ref, log, p.x, p.y);
}
Component header(boolean closable) {
JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
p.setOpaque(false);
titleLabel = new JLabel(title);
titleLabel.setFont(Theme.UI_FONT);
p.add(titleLabel);
if (closable) {
JLabel close = new JLabel("×");
close.setFont(Theme.UI_BOLD);
close.setForeground(Theme.CHAT_SYSTEM);
close.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
close.setToolTipText("Close chat");
close.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
closeTab(Tab.this);
}
});
p.add(close);
}
// Clicking the header itself must still switch tabs.
p.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
tabs.setSelectedComponent(scroll);
}
});
p.setPreferredSize(new Dimension(p.getPreferredSize().width, 20));
return p;
}
void setUnread(boolean unread) {
if (titleLabel == null) return;
titleLabel.setFont(unread ? Theme.UI_BOLD : Theme.UI_FONT);
titleLabel.setForeground(unread ? Theme.ACCENT : null);
}
void appendMessage(int fromId, String from, String text) {
String name = BBCode.escape(from);
String sender = fromId > 0
? "<a class=\"" + BBCode.IDENTITY_LINK_CLASS + "\" href=\""
+ BBCode.escape(TsLink.clientHref(fromId, "", from)) + "\">" + name + "</a>"
: "<b style=\"color:" + hex(Theme.CHAT_NAME) + "\">" + name + "</b>";
appendLine("<span style=\"color:#8A8A8A\">" + stamp() + "</span>"
+ sender + ": " + BBCode.toHtml(text));
}
void appendLine(String html) {
try {
if (prefix != null && !prefix.isEmpty()) {
doc.insertString(doc.getLength(), prefix, style(prefixColor, prefixBold));
}
if (body != null && !body.isEmpty()) {
doc.insertString(doc.getLength(), body, style(bodyColor, bodyBold));
}
doc.insertString(doc.getLength(), "\n", style(bodyColor, false));
doc.insertBeforeEnd(doc.getElement("chatlog"), "<div>" + html + "</div>");
log.setCaretPosition(doc.getLength());
} catch (BadLocationException ignored) {
} catch (BadLocationException | IOException ignored) {
}
if (tabs.getSelectedComponent() != scroll) setUnread(true);
}
}
private static SimpleAttributeSet style(Color c, boolean bold) {
SimpleAttributeSet a = new SimpleAttributeSet();
StyleConstants.setForeground(a, c);
StyleConstants.setBold(a, bold);
StyleConstants.setFontFamily(a, "SansSerif");
StyleConstants.setFontSize(a, 12);
return a;
private static String hex(java.awt.Color c) {
return String.format("#%06X", c.getRGB() & 0xFFFFFF);
}
}

View File

@@ -0,0 +1,42 @@
package com.ts3client.ui;
import com.ts3client.net.ClientEntry;
import javax.swing.JMenuItem;
import javax.swing.JPopupMenu;
/**
* The context menu for a client, shared by the server tree and the clickable
* names in the chat log.
*/
final class ClientMenu {
private ClientMenu() {
}
static JPopupMenu build(ClientEntry client, boolean self, ServerTreePanel.Actions actions) {
JPopupMenu menu = new JPopupMenu();
if (!self) {
JMenuItem pm = new JMenuItem("Open text chat");
pm.addActionListener(a -> actions.openPrivateChat(client));
menu.add(pm);
JMenuItem poke = new JMenuItem("Poke");
poke.addActionListener(a -> actions.pokeClient(client));
menu.add(poke);
menu.addSeparator();
boolean muted = actions.isClientLocallyMuted(client.id);
JMenuItem mute = new JMenuItem(muted ? "Unmute client" : "Mute client");
mute.addActionListener(a -> actions.toggleClientMute(client));
menu.add(mute);
} else {
JMenuItem me = new JMenuItem("This is you");
me.setEnabled(false);
menu.add(me);
}
menu.addSeparator();
JMenuItem info = new JMenuItem("Connection Info");
info.addActionListener(a -> actions.showConnectionInfo(client));
menu.add(info);
return menu;
}
}

View File

@@ -3,6 +3,7 @@ package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.ServerModel;
import com.ts3client.text.BBCode;
import javax.swing.BorderFactory;
import javax.swing.JEditorPane;
@@ -19,10 +20,26 @@ public final class InfoPanel extends JScrollPane {
private final JEditorPane pane = new JEditorPane();
public InfoPanel() {
pane.setContentType("text/html");
javax.swing.text.html.HTMLEditorKit kit = new javax.swing.text.html.HTMLEditorKit();
javax.swing.text.html.StyleSheet css = new javax.swing.text.html.StyleSheet();
css.addStyleSheet(kit.getStyleSheet());
css.addRule("a { color:" + hex(Theme.CHAT_NAME) + "; text-decoration:none; }");
// 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; }");
// 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; }");
kit.setStyleSheet(css);
pane.setEditorKit(kit);
pane.setEditable(false);
pane.setBackground(Theme.CHAT_BG);
pane.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
pane.addHyperlinkListener(e -> {
if (e.getEventType() == javax.swing.event.HyperlinkEvent.EventType.ACTIVATED) {
Links.open(e.getDescription(), this);
}
});
setViewportView(pane);
setBorder(BorderFactory.createLineBorder(new java.awt.Color(0xD0D0D0)));
clear();
@@ -89,12 +106,16 @@ public final class InfoPanel extends JScrollPane {
.append(label).append(":</span> ").append(value).append("</div>");
}
/** Descriptions may carry BBCode markup. */
private static String multiline(String text) {
return esc(text).replace("\n", "<br>");
return BBCode.toHtml(text);
}
private static String esc(String s) {
if (s == null) return "";
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
return BBCode.escape(s);
}
private static String hex(java.awt.Color c) {
return String.format("#%06X", c.getRGB() & 0xFFFFFF);
}
}

View File

@@ -0,0 +1,27 @@
package com.ts3client.ui;
import javax.swing.JOptionPane;
import java.awt.Component;
import java.awt.Desktop;
import java.net.URI;
/** Opens http(s) links from chat messages and descriptions in the system browser. */
final class Links {
private Links() {
}
static void open(String href, Component parent) {
try {
URI uri = new URI(href);
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase();
if (!scheme.equals("http") && !scheme.equals("https") && !scheme.equals("ftp")) return;
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(uri);
return;
}
} catch (Exception ignored) {
}
JOptionPane.showInputDialog(parent, "Could not open the link. Copy it instead:", href);
}
}

View File

@@ -11,6 +11,7 @@ import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.ConnectionListener;
import com.ts3client.net.TeamspeakConnection;
import com.ts3client.text.TsLink;
import javax.swing.BorderFactory;
import javax.swing.JButton;
@@ -102,6 +103,17 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
this.treePanel = new ServerTreePanel(conn.getModel(), this);
this.chatPanel = new ChatPanel();
chatPanel.setSendHandler(this::onSendChat);
chatPanel.setLinkHandler(new ChatPanel.LinkHandler() {
@Override
public void onClientLink(TsLink.Ref ref, Component source, int x, int y) {
MainFrame.this.onClientLink(ref, source, x, y);
}
@Override
public void onChannelLink(TsLink.Ref ref, Component source, int x, int y) {
MainFrame.this.onChannelLink(ref, source, x, y);
}
});
setJMenuBar(buildMenuBar());
add(buildToolbar(), BorderLayout.NORTH);
@@ -460,17 +472,59 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
"About TS3J", JOptionPane.INFORMATION_MESSAGE);
}
private void onSendChat(ChatPanel.Target target, String text) {
private void onSendChat(ChatPanel.Target target, int clientId, String text) {
if (!conn.isConnected()) {
chatPanel.appendSystem("Not connected.");
return;
}
if (target == ChatPanel.Target.SERVER) {
String me = settings.nickname + " (you)";
int myId = conn.getSelfClientId();
switch (target) {
case SERVER:
conn.sendServerMessage(text);
} else {
chatPanel.appendServerMessage(myId, me, text);
break;
case PRIVATE:
conn.sendPrivateMessage(clientId, text);
chatPanel.appendPrivateMessage(clientId, peerName(clientId), myId, me, text);
break;
default:
conn.sendChannelMessage(text);
chatPanel.appendChannelMessage(myId, me, text);
break;
}
chatPanel.appendMessage(settings.nickname + " (you)", text);
}
/** A client link in the chat log was clicked: show the same menu as the tree does. */
private void onClientLink(TsLink.Ref ref, Component source, int x, int y) {
ClientEntry client = conn.getModel().getClient(ref.id);
// The id is only valid for the session the link was made in; fall back to
// the unique id (and finally the nickname) so older links still resolve.
if (client == null || (!ref.uniqueId.isEmpty() && !ref.uniqueId.equals(client.uniqueId))) {
ClientEntry byUid = ref.uniqueId.isEmpty() ? null
: conn.getModel().findClientByUniqueId(ref.uniqueId);
if (byUid == null && !ref.name.isEmpty()) byUid = conn.getModel().findClientByName(ref.name);
if (byUid != null) client = byUid;
}
if (client == null) {
chatPanel.appendSystem("That client is no longer on the server.");
return;
}
ClientMenu.build(client, client.id == conn.getSelfClientId(), this).show(source, x, y);
}
private void onChannelLink(TsLink.Ref ref, Component source, int x, int y) {
ChannelNode channel = conn.getModel().getChannel(ref.id);
if (channel == null) {
chatPanel.appendSystem("That channel no longer exists.");
return;
}
ChannelMenu.build(channel, this).show(source, x, y);
}
private String peerName(int clientId) {
ClientEntry c = conn.getModel().getClient(clientId);
return c != null ? c.nickname : "Client " + clientId;
}
private void updateButtons(boolean connected) {
@@ -503,11 +557,7 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
@Override
public void openPrivateChat(ClientEntry client) {
String msg = JOptionPane.showInputDialog(this, "Message to " + client.nickname + ":");
if (msg != null && !msg.trim().isEmpty()) {
conn.sendPrivateMessage(client.id, msg.trim());
chatPanel.appendMessage("You → " + client.nickname, msg.trim());
}
chatPanel.openPrivateChat(client.id, client.nickname);
}
@Override
@@ -597,6 +647,7 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
treePanel.showDisconnected();
currentSelection = null;
infoPanel.clear();
chatPanel.closePrivateChats();
chatPanel.appendSystem("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
});
}
@@ -616,8 +667,17 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
@Override
public void onChat(ChatScope scope, int fromClientId, String fromName, String message) {
String prefix = scope == ChatScope.PRIVATE ? "[PM] " : scope == ChatScope.SERVER ? "[Server] " : "";
chatPanel.appendMessage(prefix + fromName, message);
switch (scope) {
case PRIVATE:
chatPanel.appendPrivateMessage(fromClientId, fromName, fromClientId, fromName, message);
break;
case SERVER:
chatPanel.appendServerMessage(fromClientId, fromName, message);
break;
default:
chatPanel.appendChannelMessage(fromClientId, fromName, message);
break;
}
}
@Override

View File

@@ -3,18 +3,21 @@ package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.ServerModel;
import com.ts3client.text.TsLink;
import javax.swing.ImageIcon;
import javax.swing.JMenuItem;
import javax.swing.JPopupMenu;
import javax.swing.JComponent;
import javax.swing.JScrollPane;
import javax.swing.JTree;
import javax.swing.SwingUtilities;
import javax.swing.TransferHandler;
import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.DefaultTreeModel;
import javax.swing.tree.DefaultTreeCellRenderer;
import javax.swing.tree.TreePath;
import java.awt.Component;
import java.awt.datatransfer.StringSelection;
import java.awt.datatransfer.Transferable;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.List;
@@ -67,6 +70,32 @@ public final class ServerTreePanel extends JScrollPane {
setViewportView(tree);
getViewport().setBackground(Theme.TREE_BG);
// Dragging a client or channel out of the tree yields the TS3 link BBCode,
// which the chat input accepts as plain text.
tree.setDragEnabled(true);
tree.setTransferHandler(new TransferHandler() {
@Override
public int getSourceActions(JComponent c) {
return COPY;
}
@Override
protected Transferable createTransferable(JComponent c) {
TreePath path = tree.getSelectionPath();
if (path == null) return null;
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
if (obj instanceof ClientEntry) {
ClientEntry cl = (ClientEntry) obj;
return new StringSelection(TsLink.clientBBCode(cl.id, cl.uniqueId, cl.nickname));
}
if (obj instanceof ChannelNode && !Spacers.isSpacer(((ChannelNode) obj).name)) {
ChannelNode ch = (ChannelNode) obj;
return new StringSelection(TsLink.channelBBCode(ch.id, ch.name));
}
return null;
}
});
tree.addTreeSelectionListener(e -> {
TreePath path = tree.getSelectionPath();
Object obj = null;
@@ -124,42 +153,12 @@ public final class ServerTreePanel extends JScrollPane {
}
private void showClientMenu(ClientEntry client, MouseEvent e) {
JPopupMenu menu = new JPopupMenu();
if (client.id != selfClientId) {
JMenuItem pm = new JMenuItem("Open text chat");
pm.addActionListener(a -> actions.openPrivateChat(client));
menu.add(pm);
JMenuItem poke = new JMenuItem("Poke");
poke.addActionListener(a -> actions.pokeClient(client));
menu.add(poke);
menu.addSeparator();
boolean muted = actions.isClientLocallyMuted(client.id);
JMenuItem mute = new JMenuItem(muted ? "Unmute client" : "Mute client");
mute.addActionListener(a -> actions.toggleClientMute(client));
menu.add(mute);
} else {
JMenuItem self = new JMenuItem("This is you");
self.setEnabled(false);
menu.add(self);
}
menu.addSeparator();
JMenuItem info = new JMenuItem("Connection Info");
info.addActionListener(a -> actions.showConnectionInfo(client));
menu.add(info);
menu.show(tree, e.getX(), e.getY());
ClientMenu.build(client, client.id == selfClientId, actions).show(tree, e.getX(), e.getY());
}
private void showChannelMenu(ChannelNode channel, MouseEvent e) {
if (Spacers.isSpacer(channel.name)) return; // spacers aren't interactive
JPopupMenu menu = new JPopupMenu();
JMenuItem join = new JMenuItem("Join channel");
join.addActionListener(a -> actions.joinChannel(channel.id));
menu.add(join);
menu.addSeparator();
JMenuItem files = new JMenuItem("Browse files");
files.addActionListener(a -> actions.browseFiles(channel));
menu.add(files);
menu.show(tree, e.getX(), e.getY());
ChannelMenu.build(channel, actions).show(tree, e.getX(), e.getY());
}
/** Rebuilds the tree from the model, preserving full expansion. */

View File

@@ -26,6 +26,8 @@ public final class Theme {
public static final Color CHAT_SYSTEM = new Color(0x8A8A8A);
public static final Color CHAT_NAME = new Color(0x2C7BE5);
public static final Color CHAT_TEXT = new Color(0x202020);
/** Links that leave the client, distinct from the colour used for identities. */
public static final Color LINK = new Color(0x1A5FB4);
public static final Font UI_FONT = new Font("SansSerif", Font.PLAIN, 12);
public static final Font UI_BOLD = new Font("SansSerif", Font.BOLD, 12);