package com.ts3client.ui; import com.ts3client.text.BBCode; import com.ts3client.text.TsLink; import javax.swing.BorderFactory; import javax.swing.JButton; import javax.swing.JEditorPane; import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JTabbedPane; import javax.swing.JTextField; import javax.swing.SwingUtilities; import javax.swing.event.HyperlinkEvent; import javax.swing.text.BadLocationException; import javax.swing.text.html.HTMLDocument; import javax.swing.text.html.HTMLEditorKit; import javax.swing.text.html.StyleSheet; import java.awt.BorderLayout; import java.awt.Component; import java.awt.Cursor; import java.awt.Dimension; import java.awt.FlowLayout; import java.awt.Point; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.io.IOException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.LinkedHashMap; import java.util.Map; /** * Chat area with one tab per conversation (server, current channel and one per * private chat). The tab strip sits between the log and the input line. Logs are * rendered as HTML: sender names are links that open the client context menu, * and message bodies go through the {@link BBCode} renderer. */ public final class ChatPanel extends JPanel { /** Where an outgoing message should go. */ public enum Target {CHANNEL, SERVER, PRIVATE} public interface SendHandler { /** {@code clientId} is only meaningful for {@link Target#PRIVATE}. */ void send(Target target, int clientId, String text); } /** Invoked when a client or channel link in the log is clicked. */ public interface LinkHandler { void onClientLink(TsLink.Ref ref, Component source, int x, int y); void onChannelLink(TsLink.Ref ref, Component source, int x, int y); } private final JTabbedPane tabs = new JTabbedPane(JTabbedPane.BOTTOM); private final JTextField input = new JTextField(); private final SimpleDateFormat time = new SimpleDateFormat("HH:mm:ss"); private final Tab serverTab = new Tab(Target.SERVER, 0, "Server"); private final Tab channelTab = new Tab(Target.CHANNEL, 0, "Channel"); /** Private chats keyed by the peer's client id. */ private final Map privateTabs = new LinkedHashMap<>(); private SendHandler sendHandler; private LinkHandler linkHandler; public ChatPanel() { super(new BorderLayout()); tabs.setFont(Theme.UI_FONT); addTab(serverTab, false); addTab(channelTab, false); tabs.setSelectedIndex(0); tabs.addChangeListener(e -> { Tab t = selectedTab(); if (t != null) t.setUnread(false); }); add(tabs, BorderLayout.CENTER); JPanel bottom = new JPanel(new BorderLayout(4, 0)); bottom.setBorder(BorderFactory.createEmptyBorder(4, 0, 0, 0)); bottom.add(input, BorderLayout.CENTER); JButton send = new JButton("Send"); bottom.add(send, BorderLayout.EAST); add(bottom, BorderLayout.SOUTH); send.addActionListener(e -> fireSend()); input.addActionListener(e -> fireSend()); setInputEnabled(false); } public void setSendHandler(SendHandler h) { this.sendHandler = h; } public void setLinkHandler(LinkHandler h) { this.linkHandler = h; } /** Appends text to the input line, e.g. a client link dropped onto the chat. */ public void appendToInput(String text) { edt(() -> { String cur = input.getText(); input.setText(cur.isEmpty() || cur.endsWith(" ") ? cur + text : cur + " " + text); input.requestFocusInWindow(); }); } public void setInputEnabled(boolean enabled) { input.setEnabled(enabled); } /** Opens (or focuses) the private chat with {@code clientId}. */ public void openPrivateChat(int clientId, String nickname) { edt(() -> { Tab tab = privateTab(clientId, nickname); tabs.setSelectedComponent(tab.scroll); input.requestFocusInWindow(); }); } /** Drops every private chat tab, e.g. after disconnecting. */ public void closePrivateChats() { edt(() -> { for (Tab t : privateTabs.values()) tabs.remove(t.scroll); privateTabs.clear(); }); } private void fireSend() { String text = input.getText().trim(); if (text.isEmpty() || sendHandler == null) return; Tab tab = selectedTab(); if (tab == null) return; sendHandler.send(tab.target, tab.clientId, text); input.setText(""); } // ---- append helpers (safe from any thread) ---- /** System notices always land in the server tab. */ public void appendSystem(String text) { edt(() -> serverTab.appendLine("" + stamp() + BBCode.escape(text) + "")); } public void appendServerMessage(int fromId, String from, String text) { edt(() -> serverTab.appendMessage(fromId, from, text)); } public void appendChannelMessage(int fromId, String from, String text) { edt(() -> channelTab.appendMessage(fromId, from, text)); } /** Appends to the private chat with {@code peerId}, creating the tab if needed. */ public void appendPrivateMessage(int peerId, String peerName, int fromId, String from, String text) { edt(() -> privateTab(peerId, peerName).appendMessage(fromId, from, text)); } private String stamp() { return "[" + time.format(new Date()) + "] "; } private Tab privateTab(int clientId, String nickname) { Tab tab = privateTabs.get(clientId); if (tab == null) { tab = new Tab(Target.PRIVATE, clientId, nickname); privateTabs.put(clientId, tab); addTab(tab, true); } return tab; } private void addTab(Tab tab, boolean closable) { tabs.addTab(tab.title, tab.scroll); tabs.setTabComponentAt(tabs.indexOfComponent(tab.scroll), tab.header(closable)); } private void closeTab(Tab tab) { privateTabs.remove(tab.clientId); tabs.remove(tab.scroll); } private Tab selectedTab() { Component c = tabs.getSelectedComponent(); if (c == serverTab.scroll) return serverTab; if (c == channelTab.scroll) return channelTab; for (Tab t : privateTabs.values()) { if (t.scroll == c) return t; } return null; } private void edt(Runnable r) { if (SwingUtilities.isEventDispatchThread()) r.run(); else SwingUtilities.invokeLater(r); } /** One conversation: its HTML log, its scroll pane and the tab-strip header. */ private final class Tab { final Target target; final int clientId; final String title; final JEditorPane log = new JEditorPane(); final JScrollPane scroll; private final HTMLDocument doc; private JLabel titleLabel; Tab(Target target, int clientId, String title) { this.target = target; this.clientId = clientId; this.title = title; HTMLEditorKit kit = new HTMLEditorKit(); StyleSheet css = new StyleSheet(); css.addStyleSheet(kit.getStyleSheet()); css.addRule("body { font-family:sans-serif; font-size:11px; color:#202020; margin:4px 6px; }"); css.addRule("a { color:" + hex(Theme.CHAT_NAME) + "; text-decoration:none; }"); // Client references look exactly like a message author's name. css.addRule("a." + BBCode.IDENTITY_LINK_CLASS + " { color:" + hex(Theme.CHAT_NAME) + "; font-weight:bold; text-decoration:none; }"); // External links are underlined so they can't be mistaken for an identity. css.addRule("a." + BBCode.EXTERNAL_LINK_CLASS + " { color:" + hex(Theme.LINK) + "; text-decoration:underline; }"); kit.setStyleSheet(css); log.setEditorKit(kit); log.setEditable(false); log.setBackground(Theme.CHAT_BG); log.setText("
"); 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 ? "" + 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 String hex(java.awt.Color c) { return String.format("#%06X", c.getRGB() & 0xFFFFFF); } }