diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java
index 9a4d3d4..f5d4ca7 100644
--- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java
+++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java
@@ -42,6 +42,10 @@ public final class TeamspeakConnection implements TS3Listener {
/** Upper bound for a downloaded group icon; anything larger is not an icon. */
private static final int MAX_ICON_BYTES = 1024 * 1024;
+ /** The protocol's reason ids for the two flavours of clientkick. */
+ private static final int REASON_KICK_CHANNEL = 4;
+ private static final int REASON_KICK_SERVER = 5;
+
/** Shortest gap between two "you are talking while muted" reminders. */
private static final long MUTED_TALK_COOLDOWN_NANOS = 5_000_000_000L;
@@ -520,6 +524,48 @@ public final class TeamspeakConnection implements TS3Listener {
}, "ts3j-move-client").start();
}
+ /** Kicks a client out of its channel, back into the server's default one. */
+ public void kickFromChannel(int clientId, String reason) {
+ kick(clientId, REASON_KICK_CHANNEL, reason, "Could not kick client from the channel: ");
+ }
+
+ /** Kicks a client off the server entirely. */
+ public void kickFromServer(int clientId, String reason) {
+ kick(clientId, REASON_KICK_SERVER, reason, "Could not kick client from the server: ");
+ }
+
+ private void kick(int clientId, int reasonId, String reason, String errorPrefix) {
+ new Thread(() -> {
+ try {
+ SingleCommand cmd = new SingleCommand("clientkick", ProtocolRole.CLIENT);
+ cmd.add(new CommandSingleParameter("clid", Integer.toString(clientId)));
+ cmd.add(new CommandSingleParameter("reasonid", Integer.toString(reasonId)));
+ if (reason != null && !reason.isEmpty()) {
+ cmd.add(new CommandSingleParameter("reasonmsg", reason));
+ }
+ client.executeCommand(cmd).complete();
+ } catch (Exception e) {
+ error(errorPrefix + rootMessage(e));
+ }
+ }, "ts3j-kick").start();
+ }
+
+ /**
+ * Bans a client from the server.
+ *
+ * @param seconds how long the ban lasts, or 0 for a permanent one
+ */
+ public void banClient(int clientId, long seconds, String reason) {
+ new Thread(() -> {
+ try {
+ client.banClient(clientId, seconds <= 0 ? null : (int) Math.min(seconds, Integer.MAX_VALUE),
+ reason == null || reason.isEmpty() ? null : reason);
+ } catch (Exception e) {
+ error("Could not ban client: " + rootMessage(e));
+ }
+ }, "ts3j-ban").start();
+ }
+
/**
* Re-parents and repositions a channel.
*
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/BanDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/BanDialog.java
new file mode 100644
index 0000000..7c43fac
--- /dev/null
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/BanDialog.java
@@ -0,0 +1,132 @@
+package com.ts3client.ui;
+
+import javax.swing.AbstractAction;
+import javax.swing.BorderFactory;
+import javax.swing.Box;
+import javax.swing.BoxLayout;
+import javax.swing.JButton;
+import javax.swing.JComboBox;
+import javax.swing.JComponent;
+import javax.swing.JDialog;
+import javax.swing.JPanel;
+import javax.swing.JRootPane;
+import javax.swing.JSpinner;
+import javax.swing.JTextField;
+import javax.swing.KeyStroke;
+import javax.swing.SpinnerNumberModel;
+import java.awt.BorderLayout;
+import java.awt.Component;
+import java.awt.Frame;
+import java.awt.event.ActionEvent;
+import java.awt.event.KeyEvent;
+
+/**
+ * Asks for a ban's reason and duration, like the official client's ban dialog:
+ * either a number of seconds, minutes, hours or days, or permanent.
+ */
+final class BanDialog extends JDialog {
+
+ /** The longest reason the protocol carries with a ban. */
+ private static final int REASON_LIMIT = 80;
+
+ /**
+ * The duration units the dialog offers, with the seconds each one is worth.
+ * The last entry is the permanent ban, which has no length and so takes no amount.
+ */
+ private static final String[] UNIT_NAMES = {"Seconds", "Minutes", "Hours", "Days", "Permanent"};
+ private static final long[] UNIT_SECONDS = {1, 60, 3600, 86400, 0};
+
+ private final JTextField reasonField = ReasonDialog.reasonField(REASON_LIMIT);
+ private final JSpinner amount = new JSpinner(new SpinnerNumberModel(30, 1, 999999, 1));
+ private final JComboBox unit = new JComboBox<>(UNIT_NAMES);
+
+ private boolean confirmed;
+
+ BanDialog(Frame owner, String nickname) {
+ super(owner, "Ban Client", true);
+
+ unit.setSelectedIndex(1); // minutes
+ unit.addActionListener(e -> amount.setEnabled(!isPermanent()));
+
+ JPanel form = new JPanel();
+ form.setLayout(new BoxLayout(form, BoxLayout.Y_AXIS));
+ form.setBorder(BorderFactory.createEmptyBorder(12, 12, 8, 12));
+ form.add(ReasonDialog.label("Ban " + nickname + " from the server."));
+ form.add(Box.createVerticalStrut(8));
+ form.add(ReasonDialog.label("Reason:"));
+ form.add(reasonField);
+ form.add(Box.createVerticalStrut(8));
+ form.add(ReasonDialog.label("Duration:"));
+ form.add(durationRow());
+
+ getContentPane().setLayout(new BorderLayout());
+ getContentPane().add(form, BorderLayout.CENTER);
+ getContentPane().add(buttons(), BorderLayout.SOUTH);
+
+ ReasonDialog.focusWhenShown(reasonField);
+ closeOnEscape();
+ setDefaultCloseOperation(DISPOSE_ON_CLOSE);
+ pack();
+ setResizable(false);
+ setLocationRelativeTo(owner);
+ }
+
+ private JPanel durationRow() {
+ JPanel row = new JPanel();
+ row.setLayout(new BoxLayout(row, BoxLayout.X_AXIS));
+ row.setAlignmentX(Component.LEFT_ALIGNMENT);
+ row.add(amount);
+ row.add(Box.createHorizontalStrut(4));
+ row.add(unit);
+ row.add(Box.createHorizontalGlue());
+ ReasonDialog.fixHeight(row);
+ return row;
+ }
+
+ private JPanel buttons() {
+ JPanel panel = new JPanel(new BorderLayout());
+ JPanel right = new JPanel();
+ JButton ok = new JButton("Ban");
+ JButton cancel = new JButton("Cancel");
+ ok.addActionListener(e -> {
+ confirmed = true;
+ dispose();
+ });
+ cancel.addActionListener(e -> dispose());
+ right.add(ok);
+ right.add(cancel);
+ panel.add(right, BorderLayout.EAST);
+ getRootPane().setDefaultButton(ok);
+ return panel;
+ }
+
+ /** Escape cancels the dialog, as it does in the kick prompts. */
+ private void closeOnEscape() {
+ JRootPane root = getRootPane();
+ root.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW)
+ .put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "cancel");
+ root.getActionMap().put("cancel", new AbstractAction() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ dispose();
+ }
+ });
+ }
+
+ private boolean isPermanent() {
+ return UNIT_SECONDS[unit.getSelectedIndex()] == 0;
+ }
+
+ boolean isConfirmed() {
+ return confirmed;
+ }
+
+ String getReason() {
+ return reasonField.getText().trim();
+ }
+
+ /** The ban's length in seconds, or 0 for a permanent ban. */
+ long getSeconds() {
+ return ((Number) amount.getValue()).longValue() * UNIT_SECONDS[unit.getSelectedIndex()];
+ }
+}
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
index a10b80b..eec91a7 100644
--- a/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java
@@ -24,6 +24,15 @@ final class ClientMenu {
poke.addActionListener(a -> actions.pokeClient(client));
menu.add(poke);
menu.addSeparator();
+ JMenuItem kickChannel = new JMenuItem("Kick Client from Channel", Icons.of("KICK_FROM_CHANNEL"));
+ kickChannel.addActionListener(a -> actions.kickClientFromChannel(client));
+ menu.add(kickChannel);
+ JMenuItem kickServer = new JMenuItem("Kick Client from Server", Icons.of("KICK_FROM_SERVER"));
+ kickServer.addActionListener(a -> actions.kickClientFromServer(client));
+ menu.add(kickServer);
+ JMenuItem ban = new JMenuItem("Ban Client", Icons.of("BAN_CLIENT"));
+ ban.addActionListener(a -> actions.banClient(client));
+ menu.add(ban);
boolean muted = actions.isClientLocallyMuted(client.id);
JMenuItem mute = new JMenuItem(muted ? "Unmute client" : "Mute client",
Icons.of(muted ? "PLAYER_ON" : "INPUT_MUTED"));
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ReasonDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ReasonDialog.java
new file mode 100644
index 0000000..218e2a8
--- /dev/null
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ReasonDialog.java
@@ -0,0 +1,101 @@
+package com.ts3client.ui;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.JComponent;
+import javax.swing.JLabel;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JTextField;
+import javax.swing.event.AncestorEvent;
+import javax.swing.event.AncestorListener;
+import javax.swing.text.AttributeSet;
+import javax.swing.text.BadLocationException;
+import javax.swing.text.PlainDocument;
+import java.awt.Component;
+import java.awt.Dimension;
+
+/**
+ * Asks for the reason text that goes with a moderation action. The server caps
+ * such messages, so the field refuses anything longer.
+ */
+final class ReasonDialog {
+
+ /** The longest reason the protocol carries with a kick. */
+ static final int KICK_REASON_LIMIT = 40;
+
+ private ReasonDialog() {
+ }
+
+ /** @return the reason (possibly empty), or {@code null} when the dialog was cancelled */
+ static String prompt(Component owner, String title, String message, int maxLength) {
+ JTextField field = reasonField(maxLength);
+ focusWhenShown(field);
+
+ JPanel panel = new JPanel();
+ panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
+ panel.add(label(message));
+ panel.add(field);
+
+ int result = JOptionPane.showConfirmDialog(owner, panel, title,
+ JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE);
+ return result == JOptionPane.OK_OPTION ? field.getText().trim() : null;
+ }
+
+ static JTextField reasonField(int maxLength) {
+ JTextField field = new JTextField(24);
+ field.setDocument(new LimitedDocument(maxLength));
+ field.setAlignmentX(Component.LEFT_ALIGNMENT);
+ fixHeight(field);
+ return field;
+ }
+
+ /** A left-aligned label with a little air below it, for use in a box layout. */
+ static JLabel label(String text) {
+ JLabel label = new JLabel(text);
+ label.setAlignmentX(Component.LEFT_ALIGNMENT);
+ label.setBorder(BorderFactory.createEmptyBorder(0, 0, 4, 0));
+ return label;
+ }
+
+ /** Keeps a component from stretching taller than it needs in a box layout. */
+ static void fixHeight(Component c) {
+ c.setMaximumSize(new Dimension(Integer.MAX_VALUE, c.getPreferredSize().height));
+ }
+
+ /** Puts the caret in a field as soon as its dialog appears. */
+ static void focusWhenShown(JComponent field) {
+ field.addAncestorListener(new AncestorListener() {
+ @Override
+ public void ancestorAdded(AncestorEvent event) {
+ field.requestFocusInWindow();
+ }
+
+ @Override
+ public void ancestorRemoved(AncestorEvent event) {
+ }
+
+ @Override
+ public void ancestorMoved(AncestorEvent event) {
+ }
+ });
+ }
+
+ /** A document that silently drops anything past its length limit. */
+ private static final class LimitedDocument extends PlainDocument {
+
+ private final int limit;
+
+ LimitedDocument(int limit) {
+ this.limit = limit;
+ }
+
+ @Override
+ public void insertString(int offset, String text, AttributeSet attrs) throws BadLocationException {
+ if (text == null) return;
+ int room = limit - getLength();
+ if (room <= 0) return;
+ super.insertString(offset, text.length() > room ? text.substring(0, room) : text, attrs);
+ }
+ }
+}
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java
index d7461f4..b77988d 100644
--- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java
@@ -340,6 +340,32 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
if (msg != null) conn.poke(client.id, msg);
}
+ @Override
+ public void kickClientFromChannel(ClientEntry client) {
+ String reason = kickReason("Kick Client from Channel", client);
+ if (reason != null) conn.kickFromChannel(client.id, reason);
+ }
+
+ @Override
+ public void kickClientFromServer(ClientEntry client) {
+ String reason = kickReason("Kick Client from Server", client);
+ if (reason != null) conn.kickFromServer(client.id, reason);
+ }
+
+ private String kickReason(String title, ClientEntry client) {
+ if (!conn.isConnected()) return null;
+ return ReasonDialog.prompt(host, title, "Reason for kicking " + client.nickname + ":",
+ ReasonDialog.KICK_REASON_LIMIT);
+ }
+
+ @Override
+ public void banClient(ClientEntry client) {
+ if (!conn.isConnected()) return;
+ BanDialog dialog = new BanDialog(host, client.nickname);
+ dialog.setVisible(true);
+ if (dialog.isConfirmed()) conn.banClient(client.id, dialog.getSeconds(), dialog.getReason());
+ }
+
@Override
public void toggleClientMute(ClientEntry client) {
if (conn.getPlayback() == null) return;
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 8b89d2f..ba261f0 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
@@ -47,6 +47,12 @@ public final class ServerTreePanel extends JScrollPane {
void pokeClient(ClientEntry client);
+ void kickClientFromChannel(ClientEntry client);
+
+ void kickClientFromServer(ClientEntry client);
+
+ void banClient(ClientEntry client);
+
void toggleClientMute(ClientEntry client);
void showConnectionInfo(ClientEntry client);