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:
@@ -143,6 +143,14 @@ public final class ServerModel {
|
|||||||
clients.remove(id);
|
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) {
|
public synchronized ClientEntry findClientByName(String name) {
|
||||||
for (ClientEntry c : clients.values()) {
|
for (ClientEntry c : clients.values()) {
|
||||||
if (c.nickname != null && c.nickname.equals(name)) return c;
|
if (c.nickname != null && c.nickname.equals(name)) return c;
|
||||||
|
|||||||
208
ts3-client/core/src/main/java/com/ts3client/text/BBCode.java
Normal file
208
ts3-client/core/src/main/java/com/ts3client/text/BBCode.java
Normal file
@@ -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 <html>} 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> 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> 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, "*", "<li>", "</li>");
|
||||||
|
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, "<b>", "</b>");
|
||||||
|
case "i":
|
||||||
|
return push(out, open, name, "<i>", "</i>");
|
||||||
|
case "u":
|
||||||
|
return push(out, open, name, "<u>", "</u>");
|
||||||
|
case "s":
|
||||||
|
return push(out, open, name, "<strike>", "</strike>");
|
||||||
|
case "center":
|
||||||
|
return push(out, open, name, "<div align=\"center\">", "</div>");
|
||||||
|
case "list":
|
||||||
|
return push(out, open, name, "<ul>", "</ul>");
|
||||||
|
case "color": {
|
||||||
|
if (!COLOR.matcher(value).matches()) return false;
|
||||||
|
return push(out, open, name, "<font color=\"" + value + "\">", "</font>");
|
||||||
|
}
|
||||||
|
case "size": {
|
||||||
|
int px = parseSize(value);
|
||||||
|
if (px < 0) return false;
|
||||||
|
return push(out, open, name, "<span style=\"font-size:" + px + "px\">", "</span>");
|
||||||
|
}
|
||||||
|
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> 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 "<a class=\"" + linkClass(href) + "\" href=\"" + escape(href) + "\">";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String linkClose(String href) {
|
||||||
|
return "</a>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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> open, String name,
|
||||||
|
String openHtml, String closeHtml) {
|
||||||
|
out.append(openHtml);
|
||||||
|
open.push(new Open(name, closeHtml));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean contains(Deque<Open> open, String name) {
|
||||||
|
for (Open o : open) {
|
||||||
|
if (o.name.equals(name)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean peekIs(Deque<Open> 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> open) {
|
||||||
|
if (raw.isEmpty()) return;
|
||||||
|
if (contains(open, "url")) { // don't nest links inside a link label
|
||||||
|
out.append(escape(raw).replace("\n", "<br>"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Matcher m = PLAIN_URL.matcher(raw);
|
||||||
|
int last = 0;
|
||||||
|
while (m.find()) {
|
||||||
|
out.append(escape(raw.substring(last, m.start())).replace("\n", "<br>"));
|
||||||
|
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", "<br>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Open {
|
||||||
|
final String name;
|
||||||
|
final String close;
|
||||||
|
|
||||||
|
Open(String name, String close) {
|
||||||
|
this.name = name;
|
||||||
|
this.close = close;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
110
ts3-client/core/src/main/java/com/ts3client/text/TsLink.java
Normal file
110
ts3-client/core/src/main/java/com/ts3client/text/TsLink.java
Normal file
@@ -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://<clid>/<unique id>~<nickname>} and {@code channel://<cid>/<name>}.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,65 +1,93 @@
|
|||||||
package com.ts3client.ui;
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.text.BBCode;
|
||||||
|
import com.ts3client.text.TsLink;
|
||||||
|
|
||||||
import javax.swing.BorderFactory;
|
import javax.swing.BorderFactory;
|
||||||
import javax.swing.JButton;
|
import javax.swing.JButton;
|
||||||
import javax.swing.JComboBox;
|
import javax.swing.JEditorPane;
|
||||||
|
import javax.swing.JLabel;
|
||||||
import javax.swing.JPanel;
|
import javax.swing.JPanel;
|
||||||
import javax.swing.JScrollPane;
|
import javax.swing.JScrollPane;
|
||||||
|
import javax.swing.JTabbedPane;
|
||||||
import javax.swing.JTextField;
|
import javax.swing.JTextField;
|
||||||
import javax.swing.JTextPane;
|
|
||||||
import javax.swing.SwingUtilities;
|
import javax.swing.SwingUtilities;
|
||||||
|
import javax.swing.event.HyperlinkEvent;
|
||||||
import javax.swing.text.BadLocationException;
|
import javax.swing.text.BadLocationException;
|
||||||
import javax.swing.text.SimpleAttributeSet;
|
import javax.swing.text.html.HTMLDocument;
|
||||||
import javax.swing.text.StyleConstants;
|
import javax.swing.text.html.HTMLEditorKit;
|
||||||
import javax.swing.text.StyledDocument;
|
import javax.swing.text.html.StyleSheet;
|
||||||
import java.awt.BorderLayout;
|
import java.awt.BorderLayout;
|
||||||
import java.awt.Color;
|
import java.awt.Component;
|
||||||
|
import java.awt.Cursor;
|
||||||
import java.awt.Dimension;
|
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.text.SimpleDateFormat;
|
||||||
import java.util.Date;
|
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
|
* Chat area with one tab per conversation (server, current channel and one per
|
||||||
* server). Coloured styling mimics the TS3 chat pane.
|
* 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 {
|
public final class ChatPanel extends JPanel {
|
||||||
|
|
||||||
/** Where an outgoing message should go. */
|
/** Where an outgoing message should go. */
|
||||||
public enum Target {CHANNEL, SERVER}
|
public enum Target {CHANNEL, SERVER, PRIVATE}
|
||||||
|
|
||||||
public interface SendHandler {
|
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 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 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 SendHandler sendHandler;
|
||||||
|
private LinkHandler linkHandler;
|
||||||
|
|
||||||
public ChatPanel() {
|
public ChatPanel() {
|
||||||
super(new BorderLayout());
|
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);
|
tabs.setFont(Theme.UI_FONT);
|
||||||
scroll.setBorder(BorderFactory.createLineBorder(new Color(0xD0D0D0)));
|
addTab(serverTab, false);
|
||||||
add(scroll, BorderLayout.CENTER);
|
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));
|
JPanel bottom = new JPanel(new BorderLayout(4, 0));
|
||||||
bottom.setBorder(BorderFactory.createEmptyBorder(4, 0, 0, 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);
|
bottom.add(input, BorderLayout.CENTER);
|
||||||
JButton send = new JButton("Send");
|
JButton send = new JButton("Send");
|
||||||
bottom.add(send, BorderLayout.EAST);
|
bottom.add(send, BorderLayout.EAST);
|
||||||
add(bottom, BorderLayout.SOUTH);
|
add(bottom, BorderLayout.SOUTH);
|
||||||
|
|
||||||
Runnable doSend = this::fireSend;
|
send.addActionListener(e -> fireSend());
|
||||||
send.addActionListener(e -> doSend.run());
|
input.addActionListener(e -> fireSend());
|
||||||
input.addActionListener(e -> doSend.run());
|
|
||||||
|
|
||||||
setInputEnabled(false);
|
setInputEnabled(false);
|
||||||
}
|
}
|
||||||
@@ -68,31 +96,102 @@ public final class ChatPanel extends JPanel {
|
|||||||
this.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) {
|
public void setInputEnabled(boolean enabled) {
|
||||||
input.setEnabled(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() {
|
private void fireSend() {
|
||||||
String text = input.getText().trim();
|
String text = input.getText().trim();
|
||||||
if (text.isEmpty() || sendHandler == null) return;
|
if (text.isEmpty() || sendHandler == null) return;
|
||||||
Target t = targetBox.getSelectedIndex() == 1 ? Target.SERVER : Target.CHANNEL;
|
Tab tab = selectedTab();
|
||||||
sendHandler.send(t, text);
|
if (tab == null) return;
|
||||||
|
sendHandler.send(tab.target, tab.clientId, text);
|
||||||
input.setText("");
|
input.setText("");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- append helpers (safe from any thread) ----
|
// ---- append helpers (safe from any thread) ----
|
||||||
|
|
||||||
|
/** System notices always land in the server tab. */
|
||||||
public void appendSystem(String text) {
|
public void appendSystem(String text) {
|
||||||
edt(() -> append("[" + time.format(new Date()) + "] ", Theme.CHAT_SYSTEM, false,
|
edt(() -> serverTab.appendLine("<span style=\"color:#8A8A8A\">" + stamp()
|
||||||
text, Theme.CHAT_SYSTEM, false));
|
+ BBCode.escape(text) + "</span>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void appendMessage(String from, String text) {
|
public void appendServerMessage(int fromId, String from, String text) {
|
||||||
edt(() -> {
|
edt(() -> serverTab.appendMessage(fromId, from, text));
|
||||||
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 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) {
|
private void edt(Runnable r) {
|
||||||
@@ -100,28 +199,121 @@ public final class ChatPanel extends JPanel {
|
|||||||
else SwingUtilities.invokeLater(r);
|
else SwingUtilities.invokeLater(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void append(String prefix, Color prefixColor, boolean prefixBold,
|
/** One conversation: its HTML log, its scroll pane and the tab-strip header. */
|
||||||
String body, Color bodyColor, boolean bodyBold) {
|
private final class Tab {
|
||||||
StyledDocument doc = log.getStyledDocument();
|
|
||||||
|
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 {
|
try {
|
||||||
if (prefix != null && !prefix.isEmpty()) {
|
doc.insertBeforeEnd(doc.getElement("chatlog"), "<div>" + html + "</div>");
|
||||||
doc.insertString(doc.getLength(), prefix, style(prefixColor, prefixBold));
|
|
||||||
}
|
|
||||||
if (body != null && !body.isEmpty()) {
|
|
||||||
doc.insertString(doc.getLength(), body, style(bodyColor, bodyBold));
|
|
||||||
}
|
|
||||||
doc.insertString(doc.getLength(), "\n", style(bodyColor, false));
|
|
||||||
log.setCaretPosition(doc.getLength());
|
log.setCaretPosition(doc.getLength());
|
||||||
} catch (BadLocationException ignored) {
|
} catch (BadLocationException | IOException ignored) {
|
||||||
|
}
|
||||||
|
if (tabs.getSelectedComponent() != scroll) setUnread(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SimpleAttributeSet style(Color c, boolean bold) {
|
private static String hex(java.awt.Color c) {
|
||||||
SimpleAttributeSet a = new SimpleAttributeSet();
|
return String.format("#%06X", c.getRGB() & 0xFFFFFF);
|
||||||
StyleConstants.setForeground(a, c);
|
|
||||||
StyleConstants.setBold(a, bold);
|
|
||||||
StyleConstants.setFontFamily(a, "SansSerif");
|
|
||||||
StyleConstants.setFontSize(a, 12);
|
|
||||||
return a;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package com.ts3client.ui;
|
|||||||
import com.ts3client.net.ChannelNode;
|
import com.ts3client.net.ChannelNode;
|
||||||
import com.ts3client.net.ClientEntry;
|
import com.ts3client.net.ClientEntry;
|
||||||
import com.ts3client.net.ServerModel;
|
import com.ts3client.net.ServerModel;
|
||||||
|
import com.ts3client.text.BBCode;
|
||||||
|
|
||||||
import javax.swing.BorderFactory;
|
import javax.swing.BorderFactory;
|
||||||
import javax.swing.JEditorPane;
|
import javax.swing.JEditorPane;
|
||||||
@@ -19,10 +20,26 @@ public final class InfoPanel extends JScrollPane {
|
|||||||
private final JEditorPane pane = new JEditorPane();
|
private final JEditorPane pane = new JEditorPane();
|
||||||
|
|
||||||
public InfoPanel() {
|
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.setEditable(false);
|
||||||
pane.setBackground(Theme.CHAT_BG);
|
pane.setBackground(Theme.CHAT_BG);
|
||||||
pane.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
|
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);
|
setViewportView(pane);
|
||||||
setBorder(BorderFactory.createLineBorder(new java.awt.Color(0xD0D0D0)));
|
setBorder(BorderFactory.createLineBorder(new java.awt.Color(0xD0D0D0)));
|
||||||
clear();
|
clear();
|
||||||
@@ -89,12 +106,16 @@ public final class InfoPanel extends JScrollPane {
|
|||||||
.append(label).append(":</span> ").append(value).append("</div>");
|
.append(label).append(":</span> ").append(value).append("</div>");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Descriptions may carry BBCode markup. */
|
||||||
private static String multiline(String text) {
|
private static String multiline(String text) {
|
||||||
return esc(text).replace("\n", "<br>");
|
return BBCode.toHtml(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String esc(String s) {
|
private static String esc(String s) {
|
||||||
if (s == null) return "";
|
return BBCode.escape(s);
|
||||||
return s.replace("&", "&").replace("<", "<").replace(">", ">");
|
}
|
||||||
|
|
||||||
|
private static String hex(java.awt.Color c) {
|
||||||
|
return String.format("#%06X", c.getRGB() & 0xFFFFFF);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
ts3-client/swing/src/main/java/com/ts3client/ui/Links.java
Normal file
27
ts3-client/swing/src/main/java/com/ts3client/ui/Links.java
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import com.ts3client.net.ChannelNode;
|
|||||||
import com.ts3client.net.ClientEntry;
|
import com.ts3client.net.ClientEntry;
|
||||||
import com.ts3client.net.ConnectionListener;
|
import com.ts3client.net.ConnectionListener;
|
||||||
import com.ts3client.net.TeamspeakConnection;
|
import com.ts3client.net.TeamspeakConnection;
|
||||||
|
import com.ts3client.text.TsLink;
|
||||||
|
|
||||||
import javax.swing.BorderFactory;
|
import javax.swing.BorderFactory;
|
||||||
import javax.swing.JButton;
|
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.treePanel = new ServerTreePanel(conn.getModel(), this);
|
||||||
this.chatPanel = new ChatPanel();
|
this.chatPanel = new ChatPanel();
|
||||||
chatPanel.setSendHandler(this::onSendChat);
|
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());
|
setJMenuBar(buildMenuBar());
|
||||||
add(buildToolbar(), BorderLayout.NORTH);
|
add(buildToolbar(), BorderLayout.NORTH);
|
||||||
@@ -460,17 +472,59 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
|||||||
"About TS3J", JOptionPane.INFORMATION_MESSAGE);
|
"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()) {
|
if (!conn.isConnected()) {
|
||||||
chatPanel.appendSystem("Not connected.");
|
chatPanel.appendSystem("Not connected.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (target == ChatPanel.Target.SERVER) {
|
String me = settings.nickname + " (you)";
|
||||||
|
int myId = conn.getSelfClientId();
|
||||||
|
switch (target) {
|
||||||
|
case SERVER:
|
||||||
conn.sendServerMessage(text);
|
conn.sendServerMessage(text);
|
||||||
} else {
|
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);
|
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) {
|
private void updateButtons(boolean connected) {
|
||||||
@@ -503,11 +557,7 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void openPrivateChat(ClientEntry client) {
|
public void openPrivateChat(ClientEntry client) {
|
||||||
String msg = JOptionPane.showInputDialog(this, "Message to " + client.nickname + ":");
|
chatPanel.openPrivateChat(client.id, client.nickname);
|
||||||
if (msg != null && !msg.trim().isEmpty()) {
|
|
||||||
conn.sendPrivateMessage(client.id, msg.trim());
|
|
||||||
chatPanel.appendMessage("You → " + client.nickname, msg.trim());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -597,6 +647,7 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
|||||||
treePanel.showDisconnected();
|
treePanel.showDisconnected();
|
||||||
currentSelection = null;
|
currentSelection = null;
|
||||||
infoPanel.clear();
|
infoPanel.clear();
|
||||||
|
chatPanel.closePrivateChats();
|
||||||
chatPanel.appendSystem("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
|
chatPanel.appendSystem("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -616,8 +667,17 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onChat(ChatScope scope, int fromClientId, String fromName, String message) {
|
public void onChat(ChatScope scope, int fromClientId, String fromName, String message) {
|
||||||
String prefix = scope == ChatScope.PRIVATE ? "[PM] " : scope == ChatScope.SERVER ? "[Server] " : "";
|
switch (scope) {
|
||||||
chatPanel.appendMessage(prefix + fromName, message);
|
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
|
@Override
|
||||||
|
|||||||
@@ -3,18 +3,21 @@ package com.ts3client.ui;
|
|||||||
import com.ts3client.net.ChannelNode;
|
import com.ts3client.net.ChannelNode;
|
||||||
import com.ts3client.net.ClientEntry;
|
import com.ts3client.net.ClientEntry;
|
||||||
import com.ts3client.net.ServerModel;
|
import com.ts3client.net.ServerModel;
|
||||||
|
import com.ts3client.text.TsLink;
|
||||||
|
|
||||||
import javax.swing.ImageIcon;
|
import javax.swing.ImageIcon;
|
||||||
import javax.swing.JMenuItem;
|
import javax.swing.JComponent;
|
||||||
import javax.swing.JPopupMenu;
|
|
||||||
import javax.swing.JScrollPane;
|
import javax.swing.JScrollPane;
|
||||||
import javax.swing.JTree;
|
import javax.swing.JTree;
|
||||||
import javax.swing.SwingUtilities;
|
import javax.swing.SwingUtilities;
|
||||||
|
import javax.swing.TransferHandler;
|
||||||
import javax.swing.tree.DefaultMutableTreeNode;
|
import javax.swing.tree.DefaultMutableTreeNode;
|
||||||
import javax.swing.tree.DefaultTreeModel;
|
import javax.swing.tree.DefaultTreeModel;
|
||||||
import javax.swing.tree.DefaultTreeCellRenderer;
|
import javax.swing.tree.DefaultTreeCellRenderer;
|
||||||
import javax.swing.tree.TreePath;
|
import javax.swing.tree.TreePath;
|
||||||
import java.awt.Component;
|
import java.awt.Component;
|
||||||
|
import java.awt.datatransfer.StringSelection;
|
||||||
|
import java.awt.datatransfer.Transferable;
|
||||||
import java.awt.event.MouseAdapter;
|
import java.awt.event.MouseAdapter;
|
||||||
import java.awt.event.MouseEvent;
|
import java.awt.event.MouseEvent;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -67,6 +70,32 @@ public final class ServerTreePanel extends JScrollPane {
|
|||||||
setViewportView(tree);
|
setViewportView(tree);
|
||||||
getViewport().setBackground(Theme.TREE_BG);
|
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 -> {
|
tree.addTreeSelectionListener(e -> {
|
||||||
TreePath path = tree.getSelectionPath();
|
TreePath path = tree.getSelectionPath();
|
||||||
Object obj = null;
|
Object obj = null;
|
||||||
@@ -124,42 +153,12 @@ public final class ServerTreePanel extends JScrollPane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void showClientMenu(ClientEntry client, MouseEvent e) {
|
private void showClientMenu(ClientEntry client, MouseEvent e) {
|
||||||
JPopupMenu menu = new JPopupMenu();
|
ClientMenu.build(client, client.id == selfClientId, actions).show(tree, e.getX(), e.getY());
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void showChannelMenu(ChannelNode channel, MouseEvent e) {
|
private void showChannelMenu(ChannelNode channel, MouseEvent e) {
|
||||||
if (Spacers.isSpacer(channel.name)) return; // spacers aren't interactive
|
if (Spacers.isSpacer(channel.name)) return; // spacers aren't interactive
|
||||||
JPopupMenu menu = new JPopupMenu();
|
ChannelMenu.build(channel, actions).show(tree, e.getX(), e.getY());
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rebuilds the tree from the model, preserving full expansion. */
|
/** Rebuilds the tree from the model, preserving full expansion. */
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ public final class Theme {
|
|||||||
public static final Color CHAT_SYSTEM = new Color(0x8A8A8A);
|
public static final Color CHAT_SYSTEM = new Color(0x8A8A8A);
|
||||||
public static final Color CHAT_NAME = new Color(0x2C7BE5);
|
public static final Color CHAT_NAME = new Color(0x2C7BE5);
|
||||||
public static final Color CHAT_TEXT = new Color(0x202020);
|
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_FONT = new Font("SansSerif", Font.PLAIN, 12);
|
||||||
public static final Font UI_BOLD = new Font("SansSerif", Font.BOLD, 12);
|
public static final Font UI_BOLD = new Font("SansSerif", Font.BOLD, 12);
|
||||||
|
|||||||
Reference in New Issue
Block a user