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

@@ -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();
try {
if (prefix != null && !prefix.isEmpty()) {
doc.insertString(doc.getLength(), prefix, style(prefixColor, prefixBold));
/** 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 (body != null && !body.isEmpty()) {
doc.insertString(doc.getLength(), body, style(bodyColor, bodyBold));
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);
}
doc.insertString(doc.getLength(), "\n", style(bodyColor, false));
log.setCaretPosition(doc.getLength());
} catch (BadLocationException ignored) {
// 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 {
doc.insertBeforeEnd(doc.getElement("chatlog"), "<div>" + html + "</div>");
log.setCaretPosition(doc.getLength());
} 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);
}
}