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>
320 lines
12 KiB
Java
320 lines
12 KiB
Java
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);
|
||
}
|
||
}
|