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:
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));
|
||||
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"));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user