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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user