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