diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java index 19895a5..3a14fe2 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java @@ -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; diff --git a/ts3-client/core/src/main/java/com/ts3client/text/BBCode.java b/ts3-client/core/src/main/java/com/ts3client/text/BBCode.java new file mode 100644 index 0000000..fe42a88 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/text/BBCode.java @@ -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 } 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 = 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("&", "&").replace("<", "<").replace(">", ">") + .replace("\"", """); + } + + /** True once the tag was recognised and written; false to render it literally. */ + private static boolean emitTag(StringBuilder out, String tag, Deque 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, "*", "
  • ", "
  • "); + 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, "", ""); + case "i": + return push(out, open, name, "", ""); + case "u": + return push(out, open, name, "", ""); + case "s": + return push(out, open, name, "", ""); + case "center": + return push(out, open, name, "
    ", "
    "); + case "list": + return push(out, open, name, ""); + case "color": { + if (!COLOR.matcher(value).matches()) return false; + return push(out, open, name, "", ""); + } + case "size": { + int px = parseSize(value); + if (px < 0) return false; + return push(out, open, name, "", ""); + } + 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) { + 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 ""; + } + + private static String linkClose(String href) { + return ""; + } + + /** 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, String name, + String openHtml, String closeHtml) { + out.append(openHtml); + open.push(new Open(name, closeHtml)); + return true; + } + + private static boolean contains(Deque open, String name) { + for (Open o : open) { + if (o.name.equals(name)) return true; + } + return false; + } + + private static boolean peekIs(Deque 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) { + if (raw.isEmpty()) return; + if (contains(open, "url")) { // don't nest links inside a link label + out.append(escape(raw).replace("\n", "
    ")); + return; + } + Matcher m = PLAIN_URL.matcher(raw); + int last = 0; + while (m.find()) { + out.append(escape(raw.substring(last, m.start())).replace("\n", "
    ")); + 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", "
    ")); + } + + private static final class Open { + final String name; + final String close; + + Open(String name, String close) { + this.name = name; + this.close = close; + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/text/TsLink.java b/ts3-client/core/src/main/java/com/ts3client/text/TsLink.java new file mode 100644 index 0000000..0e97dff --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/text/TsLink.java @@ -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:///~} and {@code channel:///}. + */ +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; + } + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelMenu.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelMenu.java new file mode 100644 index 0000000..b3dcabf --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelMenu.java @@ -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; + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java index dc6596b..ed678cb 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java @@ -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 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 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("" + stamp() + + BBCode.escape(text) + "")); } - 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("
    "); + 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 + ? "" + name + "" + : "" + name + ""; + appendLine("" + stamp() + "" + + sender + ": " + BBCode.toHtml(text)); + } + + void appendLine(String html) { + try { + doc.insertBeforeEnd(doc.getElement("chatlog"), "
    " + html + "
    "); + 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); } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java new file mode 100644 index 0000000..813f9e3 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java @@ -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; + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java index d34e965..bb573fb 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java @@ -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(": ").append(value).append(""); } + /** Descriptions may carry BBCode markup. */ private static String multiline(String text) { - return esc(text).replace("\n", "
    "); + return BBCode.toHtml(text); } private static String esc(String s) { - if (s == null) return ""; - return s.replace("&", "&").replace("<", "<").replace(">", ">"); + return BBCode.escape(s); + } + + private static String hex(java.awt.Color c) { + return String.format("#%06X", c.getRGB() & 0xFFFFFF); } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/Links.java b/ts3-client/swing/src/main/java/com/ts3client/ui/Links.java new file mode 100644 index 0000000..ff30f9c --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/Links.java @@ -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); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index 8f7f667..bb4f6f4 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -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) { - conn.sendServerMessage(text); - } else { - conn.sendChannelMessage(text); + String me = settings.nickname + " (you)"; + int myId = conn.getSelfClientId(); + switch (target) { + case SERVER: + conn.sendServerMessage(text); + 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 diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java index 4ba6276..4a20e29 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java @@ -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. */ diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java b/ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java index e31cbed..f405428 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java @@ -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);