Files
ts3java/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java
ericek111 707d9ebbba 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>
2026-08-13 13:50:04 +00:00

320 lines
12 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.JEditorPane;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTabbedPane;
import javax.swing.JTextField;
import javax.swing.SwingUtilities;
import javax.swing.event.HyperlinkEvent;
import javax.swing.text.BadLocationException;
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.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 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, PRIVATE}
public interface SendHandler {
/** {@code clientId} is only meaningful for {@link Target#PRIVATE}. */
void send(Target target, int clientId, String text);
}
/** 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 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());
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));
bottom.add(input, BorderLayout.CENTER);
JButton send = new JButton("Send");
bottom.add(send, BorderLayout.EAST);
add(bottom, BorderLayout.SOUTH);
send.addActionListener(e -> fireSend());
input.addActionListener(e -> fireSend());
setInputEnabled(false);
}
public void setSendHandler(SendHandler h) {
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);
}
/** 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;
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(() -> serverTab.appendLine("<span style=\"color:#8A8A8A\">" + stamp()
+ BBCode.escape(text) + "</span>"));
}
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) {
if (SwingUtilities.isEventDispatchThread()) r.run();
else SwingUtilities.invokeLater(r);
}
/** 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 {
doc.insertBeforeEnd(doc.getElement("chatlog"), "<div>" + html + "</div>");
log.setCaretPosition(doc.getLength());
} catch (BadLocationException | IOException ignored) {
}
if (tabs.getSelectedComponent() != scroll) setUnread(true);
}
}
private static String hex(java.awt.Color c) {
return String.format("#%06X", c.getRGB() & 0xFFFFFF);
}
}