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:
@@ -42,6 +42,10 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
/** Upper bound for a downloaded group icon; anything larger is not an icon. */
|
/** Upper bound for a downloaded group icon; anything larger is not an icon. */
|
||||||
private static final int MAX_ICON_BYTES = 1024 * 1024;
|
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. */
|
/** Shortest gap between two "you are talking while muted" reminders. */
|
||||||
private static final long MUTED_TALK_COOLDOWN_NANOS = 5_000_000_000L;
|
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();
|
}, "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.
|
* Re-parents and repositions a channel.
|
||||||
*
|
*
|
||||||
|
|||||||
132
ts3-client/swing/src/main/java/com/ts3client/ui/BanDialog.java
Normal file
132
ts3-client/swing/src/main/java/com/ts3client/ui/BanDialog.java
Normal 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()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,15 @@ final class ClientMenu {
|
|||||||
poke.addActionListener(a -> actions.pokeClient(client));
|
poke.addActionListener(a -> actions.pokeClient(client));
|
||||||
menu.add(poke);
|
menu.add(poke);
|
||||||
menu.addSeparator();
|
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);
|
boolean muted = actions.isClientLocallyMuted(client.id);
|
||||||
JMenuItem mute = new JMenuItem(muted ? "Unmute client" : "Mute client",
|
JMenuItem mute = new JMenuItem(muted ? "Unmute client" : "Mute client",
|
||||||
Icons.of(muted ? "PLAYER_ON" : "INPUT_MUTED"));
|
Icons.of(muted ? "PLAYER_ON" : "INPUT_MUTED"));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -340,6 +340,32 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
|||||||
if (msg != null) conn.poke(client.id, msg);
|
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
|
@Override
|
||||||
public void toggleClientMute(ClientEntry client) {
|
public void toggleClientMute(ClientEntry client) {
|
||||||
if (conn.getPlayback() == null) return;
|
if (conn.getPlayback() == null) return;
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ public final class ServerTreePanel extends JScrollPane {
|
|||||||
|
|
||||||
void pokeClient(ClientEntry client);
|
void pokeClient(ClientEntry client);
|
||||||
|
|
||||||
|
void kickClientFromChannel(ClientEntry client);
|
||||||
|
|
||||||
|
void kickClientFromServer(ClientEntry client);
|
||||||
|
|
||||||
|
void banClient(ClientEntry client);
|
||||||
|
|
||||||
void toggleClientMute(ClientEntry client);
|
void toggleClientMute(ClientEntry client);
|
||||||
|
|
||||||
void showConnectionInfo(ClientEntry client);
|
void showConnectionInfo(ClientEntry client);
|
||||||
|
|||||||
Reference in New Issue
Block a user