Kick and ban clients from the context menu

The client menu gains "Kick Client from Channel", "Kick Client from
Server" and "Ban Client", each asking for a reason first. Kicks use a
shared prompt that enforces the protocol's 40-character limit; the ban
dialog adds a duration, whose unit dropdown ends in "Permanent" — the
zero-length ban the server understands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 14:29:21 +00:00
parent d37c6c9ba9
commit 0f8de796ea
6 changed files with 320 additions and 0 deletions

View File

@@ -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 <b>clientkick</b>. */
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.
*

View File

@@ -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<String> 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()];
}
}

View File

@@ -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"));

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -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);