Add the Tools > Ban List window

Reimplements the official client's ban list: a searchable table of the
server's bans with Add / Remove / Edit, an own-ban filter and highlight,
choosable columns, and the Add/Edit dialog whose IP and name patterns
can be read as IPv4/IPv6 wildcards, fixed strings or regular expressions
(translated to the regexes banadd takes exactly as the official client
does). Editing is add-then-delete, since the protocol has no banedit.

The ban list is read from the notifybanlist burst that answers banlist,
like channelpermlist. The menu item is only shown where the server grants
b_client_ban_list.

Also fixes ServerModel numbering permissions from 0 instead of 1, which
had every selfPermissionValue() lookup reading the neighbouring
permission — including the group-assignment menus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 23:00:18 +00:00
parent 636090d075
commit 22e0304f25
20 changed files with 1705 additions and 44 deletions

View File

@@ -0,0 +1,256 @@
package com.ts3client.ui;
import com.ts3client.config.BanReasonPresets;
import com.ts3client.net.ban.BanEntry;
import com.ts3client.net.ban.BanPattern;
import com.ts3client.net.ban.BanRule;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTextField;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Frame;
/**
* The ban list's "Add Ban" / "Edit Ban" dialog, laid out like the official client's:
* an IP and a nickname pattern with a choice of how to read them, a unique
* identifier, a myTeamSpeak id, the reason (with saved presets) and the duration.
*
* <p>OK stays disabled until the ban has at least one thing to match and its patterns
* are valid expressions; a pattern the server would reject is flagged in red.
*/
final class BanAddDialog extends JDialog {
private static final String NO_PRESET = "None";
private static final Color INVALID_BG = new Color(0xC0, 0x00, 0x00);
private final BanReasonPresets presets;
private final Color fieldBg;
private final Color fieldFg;
private final JTextField ipField = new JTextField(30);
private final JTextField nameField = new JTextField(30);
private final JComboBox<BanPattern> patternBox = new JComboBox<>(BanPattern.values());
private final JTextField uidField = new JTextField(30);
private final JTextField mytsidField = new JTextField(30);
private final JComboBox<String> presetBox = new JComboBox<>();
private final JTextField reasonField = ReasonDialog.reasonField(BanRule.REASON_LIMIT);
private final BanDurationPicker duration;
private final JButton ok = new JButton("OK");
private BanRule result;
/**
* @param existing the ban being edited, or {@code null} to add a new one
* @param maxSeconds the longest ban the server lets us hand out, or 0 for no limit
*/
BanAddDialog(Frame owner, BanReasonPresets presets, BanEntry existing, long maxSeconds) {
super(owner, existing == null ? "Add Ban" : "Edit Ban", true);
this.presets = presets;
this.duration = new BanDurationPicker(maxSeconds);
fieldBg = ipField.getBackground();
fieldFg = ipField.getForeground();
patternBox.setRenderer(new PatternRenderer());
patternBox.addActionListener(e -> checkValid());
for (JTextField f : new JTextField[] {ipField, nameField, uidField, mytsidField}) {
f.setAlignmentX(Component.LEFT_ALIGNMENT);
ReasonDialog.fixHeight(f);
onEdit(f, this::checkValid);
}
duration.onChange(this::checkValid);
reasonField.addActionListener(e -> {
if (ok.isEnabled()) accept();
});
JPanel form = new JPanel();
form.setLayout(new BoxLayout(form, BoxLayout.Y_AXIS));
form.setBorder(BorderFactory.createEmptyBorder(12, 12, 8, 12));
form.add(ReasonDialog.label("IP:"));
form.add(ipField);
form.add(Box.createVerticalStrut(8));
form.add(ReasonDialog.label("Name:"));
form.add(nameField);
form.add(Box.createVerticalStrut(8));
form.add(patternRow());
form.add(Box.createVerticalStrut(8));
form.add(ReasonDialog.label("Unique ID:"));
form.add(uidField);
form.add(Box.createVerticalStrut(8));
form.add(ReasonDialog.label("myTeamSpeak ID:"));
form.add(mytsidField);
form.add(Box.createVerticalStrut(12));
form.add(reasonRow());
form.add(reasonField);
form.add(Box.createVerticalStrut(8));
form.add(ReasonDialog.label("Duration:"));
form.add(duration);
getContentPane().setLayout(new BorderLayout());
getContentPane().add(form, BorderLayout.CENTER);
getContentPane().add(buttons(), BorderLayout.SOUTH);
if (existing != null) load(existing);
rebuildPresets();
checkValid();
ReasonDialog.focusWhenShown(ipField);
Dialogs.closeOnEscape(this);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
pack();
setResizable(false);
setLocationRelativeTo(owner);
}
// ---- construction ----
private JPanel patternRow() {
JPanel row = new JPanel();
row.setLayout(new BoxLayout(row, BoxLayout.X_AXIS));
row.setAlignmentX(Component.LEFT_ALIGNMENT);
row.add(new JLabel("Interpret IP/Name as:"));
row.add(Box.createHorizontalStrut(8));
row.add(patternBox);
row.add(Box.createHorizontalGlue());
ReasonDialog.fixHeight(row);
return row;
}
private JPanel reasonRow() {
JButton save = new JButton("Save preset");
save.addActionListener(e -> savePreset());
presetBox.addActionListener(e -> {
int i = presetBox.getSelectedIndex();
if (i > 0) reasonField.setText(presetBox.getItemAt(i));
});
JPanel row = new JPanel();
row.setLayout(new BoxLayout(row, BoxLayout.X_AXIS));
row.setAlignmentX(Component.LEFT_ALIGNMENT);
row.setBorder(BorderFactory.createEmptyBorder(0, 0, 4, 0));
row.add(new JLabel("Reason:"));
row.add(Box.createHorizontalGlue());
row.add(new JLabel("Preset:"));
row.add(Box.createHorizontalStrut(6));
row.add(presetBox);
row.add(Box.createHorizontalStrut(6));
row.add(save);
ReasonDialog.fixHeight(row);
return row;
}
private JPanel buttons() {
JPanel panel = new JPanel(new BorderLayout());
JPanel right = new JPanel();
JButton cancel = new JButton("Cancel");
ok.addActionListener(e -> accept());
cancel.addActionListener(e -> dispose());
right.add(ok);
right.add(cancel);
panel.add(right, BorderLayout.EAST);
getRootPane().setDefaultButton(ok);
return panel;
}
// ---- behaviour ----
/** Shows an existing ban the way it was most likely typed in. */
private void load(BanEntry ban) {
BanPattern.Decoded decoded = BanPattern.decode(ban.ip, ban.name);
patternBox.setSelectedItem(decoded.pattern);
ipField.setText(decoded.ip);
nameField.setText(decoded.name);
uidField.setText(ban.uid);
mytsidField.setText(ban.mytsid);
reasonField.setText(ban.reason);
duration.setSeconds(ban.durationSeconds);
}
private BanPattern pattern() {
return (BanPattern) patternBox.getSelectedItem();
}
private BanRule rule() {
return new BanRule(pattern().ipToRegex(ipField.getText()), pattern().nameToRegex(nameField.getText()),
uidField.getText(), mytsidField.getText(), duration.getSeconds(), reasonField.getText());
}
private void checkValid() {
BanRule rule = rule();
boolean ipOk = BanPattern.isValidRegex(rule.ipRegex);
boolean nameOk = BanPattern.isValidRegex(rule.nameRegex);
flag(ipField, ipOk);
flag(nameField, nameOk);
ok.setEnabled(rule.hasTarget() && ipOk && nameOk);
}
private void flag(JTextField field, boolean valid) {
field.setBackground(valid ? fieldBg : INVALID_BG);
field.setForeground(valid ? fieldFg : Color.WHITE);
}
private void accept() {
result = rule();
dispose();
}
private void savePreset() {
String reason = reasonField.getText().trim();
if (reason.isEmpty()) return;
presets.add(reason);
presets.save();
rebuildPresets();
presetBox.setSelectedIndex(1);
}
private void rebuildPresets() {
presetBox.removeAllItems();
presetBox.addItem(NO_PRESET);
for (String preset : presets.all()) presetBox.addItem(preset);
presetBox.setSelectedIndex(0);
}
private static void onEdit(JTextField field, Runnable listener) {
field.getDocument().addDocumentListener(new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent e) {
listener.run();
}
@Override
public void removeUpdate(DocumentEvent e) {
listener.run();
}
@Override
public void changedUpdate(DocumentEvent e) {
listener.run();
}
});
}
/** The ban as confirmed with OK, or {@code null} when the dialog was cancelled. */
BanRule getRule() {
return result;
}
private static final class PatternRenderer extends javax.swing.DefaultListCellRenderer {
@Override
public Component getListCellRendererComponent(javax.swing.JList<?> list, Object value, int index,
boolean selected, boolean focused) {
super.getListCellRendererComponent(list, value, index, selected, focused);
if (value instanceof BanPattern) setText(((BanPattern) value).label());
return this;
}
}
}

View File

@@ -1,17 +1,15 @@
package com.ts3client.ui;
import com.ts3client.net.ban.BanRule;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JDialog;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.JTextField;
import javax.swing.SpinnerNumberModel;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Frame;
/**
@@ -20,27 +18,17 @@ import java.awt.Frame;
*/
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 final JTextField reasonField = ReasonDialog.reasonField(BanRule.REASON_LIMIT);
private final BanDurationPicker duration;
private boolean confirmed;
BanDialog(Frame owner, String nickname) {
/**
* @param maxSeconds the longest ban the server lets us hand out, or 0 for no limit
*/
BanDialog(Frame owner, String nickname, long maxSeconds) {
super(owner, "Ban Client", true);
unit.setSelectedIndex(1); // minutes
unit.addActionListener(e -> amount.setEnabled(!isPermanent()));
duration = new BanDurationPicker(maxSeconds);
JPanel form = new JPanel();
form.setLayout(new BoxLayout(form, BoxLayout.Y_AXIS));
@@ -51,7 +39,7 @@ final class BanDialog extends JDialog {
form.add(reasonField);
form.add(Box.createVerticalStrut(8));
form.add(ReasonDialog.label("Duration:"));
form.add(durationRow());
form.add(duration);
getContentPane().setLayout(new BorderLayout());
getContentPane().add(form, BorderLayout.CENTER);
@@ -65,18 +53,6 @@ final class BanDialog extends JDialog {
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();
@@ -94,10 +70,6 @@ final class BanDialog extends JDialog {
return panel;
}
private boolean isPermanent() {
return UNIT_SECONDS[unit.getSelectedIndex()] == 0;
}
boolean isConfirmed() {
return confirmed;
}
@@ -108,6 +80,6 @@ final class BanDialog extends JDialog {
/** The ban's length in seconds, or 0 for a permanent ban. */
long getSeconds() {
return ((Number) amount.getValue()).longValue() * UNIT_SECONDS[unit.getSelectedIndex()];
return duration.getSeconds();
}
}

View File

@@ -0,0 +1,84 @@
package com.ts3client.ui;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.SpinnerNumberModel;
import java.awt.Component;
/**
* A ban's length as the official client asks for it: an amount of seconds, minutes,
* hours or days, or "permanent", plus a button that jumps to the longest ban the
* server lets us hand out ({@code i_client_ban_max_bantime}).
*/
final class BanDurationPicker extends JPanel {
/**
* The units offered, 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 static final int PERMANENT = UNIT_NAMES.length - 1;
private final JSpinner amount = new JSpinner(new SpinnerNumberModel(30, 1, 999_999_999, 1));
private final JComboBox<String> unit = new JComboBox<>(UNIT_NAMES);
private final JButton max = new JButton("Max");
/**
* @param maxSeconds the longest ban the server allows, or 0 when there is no limit
*/
BanDurationPicker(long maxSeconds) {
setLayout(new BoxLayout(this, BoxLayout.X_AXIS));
setAlignmentX(Component.LEFT_ALIGNMENT);
setOpaque(false);
unit.setSelectedIndex(1); // minutes
unit.addActionListener(e -> amount.setEnabled(!isPermanent()));
max.setToolTipText("Set duration to maximum value");
max.addActionListener(e -> setSeconds(maxSeconds));
add(amount);
add(Box.createHorizontalStrut(4));
add(unit);
add(Box.createHorizontalStrut(4));
add(max);
add(Box.createHorizontalGlue());
ReasonDialog.fixHeight(this);
}
boolean isPermanent() {
return UNIT_SECONDS[unit.getSelectedIndex()] == 0;
}
/** The ban's length in seconds, or 0 for a permanent ban. */
long getSeconds() {
if (isPermanent()) return 0;
return ((Number) amount.getValue()).longValue() * UNIT_SECONDS[unit.getSelectedIndex()];
}
/** Shows a length in the largest unit that divides it evenly; 0 is permanent. */
void setSeconds(long seconds) {
if (seconds <= 0) {
unit.setSelectedIndex(PERMANENT);
return;
}
int i = PERMANENT - 1;
while (i > 0 && seconds % UNIT_SECONDS[i] != 0) i--;
unit.setSelectedIndex(i);
amount.setValue((int) Math.min(seconds / UNIT_SECONDS[i], 999_999_999));
}
/** Called back whenever the length changes, however it changes. */
void onChange(Runnable listener) {
amount.addChangeListener(e -> listener.run());
unit.addActionListener(e -> listener.run());
}
JSpinner amountField() {
return amount;
}
}

View File

@@ -0,0 +1,666 @@
package com.ts3client.ui;
import com.ts3client.config.BanReasonPresets;
import com.ts3client.config.Settings;
import com.ts3client.net.ServerModel;
import com.ts3client.net.TeamspeakConnection;
import com.ts3client.net.ban.BanEntry;
import com.ts3client.net.ban.BanRule;
import javax.swing.AbstractAction;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.KeyStroke;
import javax.swing.ListSelectionModel;
import javax.swing.RowFilter;
import javax.swing.SwingUtilities;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.table.AbstractTableModel;
import javax.swing.table.DefaultTableCellRenderer;
import javax.swing.table.JTableHeader;
import javax.swing.table.TableColumn;
import javax.swing.table.TableColumnModel;
import javax.swing.table.TableRowSorter;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Frame;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
/**
* The server's ban list, as the official client's Tools &gt; Ban List window: Add,
* Remove and Edit above a searchable table, Reload and the own-ban filters below it.
* Which columns show is chosen from the header's context menu and remembered.
*
* <p>One per server tab and modeless, so it can stay open while the server is used;
* the tab closes it when the connection goes away.
*/
final class BanListDialog extends JDialog {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/** The table's columns, in the official order; the split-out target columns start hidden. */
private enum Column {
TARGETS("Name/IP/UID/myTSID", 280, true),
NAME("Name", 120, false),
IP("IP", 120, false),
UID("UID", 200, false),
MYTSID("myTSID", 200, false),
REASON("Reason", 160, true),
CREATOR("Creator", 100, true),
CREATED("Created", 140, true),
EXPIRES("Expires", 140, true),
ENFORCED("Enforced", 70, true);
final String title;
final int width;
final boolean shownByDefault;
Column(String title, int width, boolean shownByDefault) {
this.title = title;
this.width = width;
this.shownByDefault = shownByDefault;
}
}
private final Frame owner;
private final TeamspeakConnection conn;
private final Settings settings;
private final BanReasonPresets presets;
private final BanTableModel tableModel = new BanTableModel();
private final JTable table = new BanTable();
private final TableRowSorter<BanTableModel> sorter = new TableRowSorter<>(tableModel);
/** Every column the table can show, in model order, whether currently shown or not. */
private final List<TableColumn> allColumns = new ArrayList<>();
private final Set<Column> visibleColumns = EnumSet.noneOf(Column.class);
private final JButton addButton = new JButton("Add", Icons.of("ADD"));
private final JButton removeButton = new JButton("Remove", Icons.of("DELETE"));
private final JButton editButton = new JButton("Edit", Icons.of("EDIT"));
private final SearchField searchField = new SearchField();
private final JCheckBox onlyOwn = new JCheckBox("Show only own bans");
private final JCheckBox highlightOwn = new JCheckBox("Highlight own bans");
private final JLabel status = new JLabel(" ");
private boolean loading;
BanListDialog(Frame owner, TeamspeakConnection conn, Settings settings, BanReasonPresets presets) {
super(owner, "Ban List", false);
this.owner = owner;
this.conn = conn;
this.settings = settings;
this.presets = presets;
JPanel content = new JPanel(new BorderLayout(0, 6));
content.setBackground(Theme.windowBg());
content.setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12));
content.add(buildToolbar(), BorderLayout.NORTH);
content.add(buildTable(), BorderLayout.CENTER);
content.add(buildBottom(), BorderLayout.SOUTH);
setContentPane(content);
bindKeys();
serverNameChanged();
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setPreferredSize(new Dimension(920, 460));
pack();
setLocationRelativeTo(owner);
reload();
}
// ---- construction ----
private JPanel buildToolbar() {
JPanel bar = new JPanel();
bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS));
bar.setOpaque(false);
addButton.setToolTipText("<html>Add new ban<br>(Ctrl+N)");
removeButton.setToolTipText("<html>Remove selected bans<br>(Delete)");
editButton.setToolTipText("<html>Edit selected ban<br>(Return)");
searchField.setToolTipText("<html>Search bans by text<br>(Ctrl+F)");
addButton.addActionListener(e -> addBan());
removeButton.addActionListener(e -> removeBans());
editButton.addActionListener(e -> editBan());
searchField.getDocument().addDocumentListener(new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent e) {
applyFilter();
}
@Override
public void removeUpdate(DocumentEvent e) {
applyFilter();
}
@Override
public void changedUpdate(DocumentEvent e) {
applyFilter();
}
});
searchField.setMaximumSize(new Dimension(Integer.MAX_VALUE, searchField.getPreferredSize().height));
bar.add(addButton);
bar.add(Box.createHorizontalStrut(6));
bar.add(removeButton);
bar.add(Box.createHorizontalStrut(6));
bar.add(editButton);
bar.add(Box.createHorizontalStrut(16));
bar.add(searchField);
return bar;
}
private JScrollPane buildTable() {
table.setFont(Theme.uiFont());
table.setRowHeight(20);
table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
table.setFillsViewportHeight(true);
table.setAutoCreateColumnsFromModel(false);
table.setRowSorter(sorter);
table.getSelectionModel().addListSelectionListener(e -> updateButtons());
table.setComponentPopupMenu(tablePopup());
table.addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2
&& table.rowAtPoint(e.getPoint()) >= 0) {
editBan();
}
}
});
BanCellRenderer renderer = new BanCellRenderer();
for (Column c : Column.values()) {
TableColumn column = new TableColumn(c.ordinal(), c.width);
column.setHeaderValue(c.title);
column.setCellRenderer(renderer);
allColumns.add(column);
}
visibleColumns.addAll(savedColumns());
applyColumns();
JTableHeader header = table.getTableHeader();
header.setReorderingAllowed(true);
header.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
if (e.isPopupTrigger()) headerPopup().show(header, e.getX(), e.getY());
}
@Override
public void mouseReleased(MouseEvent e) {
if (e.isPopupTrigger()) headerPopup().show(header, e.getX(), e.getY());
}
});
JScrollPane scroll = new JScrollPane(table);
scroll.getViewport().setBackground(Theme.treeBg());
return scroll;
}
private JPanel buildBottom() {
JButton reload = new JButton("Reload", Icons.of("REFRESH"));
reload.addActionListener(e -> reload());
onlyOwn.setToolTipText("Show only bans created by you");
onlyOwn.setOpaque(false);
onlyOwn.addActionListener(e -> applyFilter());
highlightOwn.setToolTipText("Mark bans created by you as bold");
highlightOwn.setOpaque(false);
highlightOwn.setSelected(settings.banHighlightOwn);
highlightOwn.addActionListener(e -> {
settings.banHighlightOwn = highlightOwn.isSelected();
settings.save();
table.repaint();
});
JPanel options = new JPanel();
options.setLayout(new BoxLayout(options, BoxLayout.X_AXIS));
options.setOpaque(false);
options.add(reload);
options.add(Box.createHorizontalStrut(12));
options.add(onlyOwn);
options.add(Box.createHorizontalStrut(12));
options.add(highlightOwn);
options.add(Box.createHorizontalGlue());
JButton close = new JButton("Close");
close.addActionListener(e -> dispose());
JPanel closeRow = new JPanel(new BorderLayout());
closeRow.setOpaque(false);
closeRow.add(close, BorderLayout.EAST);
JPanel bottom = new JPanel(new BorderLayout(0, 8));
bottom.setOpaque(false);
bottom.add(status, BorderLayout.NORTH);
bottom.add(options, BorderLayout.CENTER);
bottom.add(closeRow, BorderLayout.SOUTH);
status.setForeground(Theme.chatEvent());
return bottom;
}
private JPopupMenu tablePopup() {
JPopupMenu menu = new JPopupMenu();
menu.add(item("Add", "ADD", this::addBan));
menu.add(item("Remove", "DELETE", this::removeBans));
menu.add(item("Edit", "EDIT", this::editBan));
menu.addSeparator();
menu.add(item("Reload", "REFRESH", this::reload));
return menu;
}
private static JMenuItem item(String text, String icon, Runnable action) {
JMenuItem item = new JMenuItem(text, Icons.of(icon));
item.addActionListener(e -> action.run());
return item;
}
private JPopupMenu headerPopup() {
JPopupMenu menu = new JPopupMenu();
for (Column c : Column.values()) {
JCheckBoxMenuItem item = new JCheckBoxMenuItem(c.title, visibleColumns.contains(c));
// the last column stays, or there would be nothing left to right-click on
item.setEnabled(!item.isSelected() || visibleColumns.size() > 1);
item.addActionListener(e -> {
if (item.isSelected()) visibleColumns.add(c);
else visibleColumns.remove(c);
applyColumns();
saveColumns();
});
menu.add(item);
}
return menu;
}
private void bindKeys() {
bind(getRootPane(), JComponent.WHEN_IN_FOCUSED_WINDOW, KeyStroke.getKeyStroke("control N"), this::addBan);
bind(getRootPane(), JComponent.WHEN_IN_FOCUSED_WINDOW, KeyStroke.getKeyStroke("control F"),
searchField::requestFocusInWindow);
bind(getRootPane(), JComponent.WHEN_IN_FOCUSED_WINDOW, KeyStroke.getKeyStroke("control W"), this::dispose);
bind(table, JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT, KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, 0),
this::removeBans);
bind(table, JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT, KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
this::editBan);
Dialogs.closeOnEscape(this);
}
private static void bind(JComponent target, int condition, KeyStroke key, Runnable action) {
String name = "ts3-banlist-" + key;
target.getInputMap(condition).put(key, name);
target.getActionMap().put(name, new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
action.run();
}
});
}
// ---- columns ----
private Set<Column> savedColumns() {
Set<Column> columns = EnumSet.noneOf(Column.class);
for (String name : settings.banListColumns.split(",")) {
try {
if (!name.isBlank()) columns.add(Column.valueOf(name.trim()));
} catch (IllegalArgumentException ignored) {
// a column from another version; skipped
}
}
if (columns.isEmpty()) {
for (Column c : Column.values()) if (c.shownByDefault) columns.add(c);
}
return columns;
}
private void saveColumns() {
settings.banListColumns = visibleColumns.stream().map(Enum::name).collect(Collectors.joining(","));
settings.save();
}
/** Rebuilds the column model so it holds exactly the visible columns, in model order. */
private void applyColumns() {
TableColumnModel columns = table.getColumnModel();
while (columns.getColumnCount() > 0) columns.removeColumn(columns.getColumn(0));
for (TableColumn column : allColumns) {
if (visibleColumns.contains(Column.values()[column.getModelIndex()])) columns.addColumn(column);
}
}
// ---- data ----
/** Follows the server's name, as the official window's "Ban List - <server>" title does. */
void serverNameChanged() {
String name = conn.getModel().getServerName();
setTitle(name == null || name.isBlank() ? "Ban List" : "Ban List - " + name);
}
/** The connection is gone; a ban list without a server is meaningless. */
void connectionLost() {
dispose();
}
private void reload() {
if (loading) return;
loading = true;
status.setText("Loading…");
conn.requestBanList((bans, error) -> SwingUtilities.invokeLater(() -> {
loading = false;
if (!isDisplayable()) return;
if (error != null) {
tableModel.setBans(List.of());
status.setText(isPermissionError(error)
? "You have insufficient permissions to view the ban list."
: "Failed to request ban list: " + error);
updateButtons();
return;
}
tableModel.setBans(bans);
status.setText(bans.size() == 1 ? "1 ban" : bans.size() + " bans");
updateButtons();
}));
}
private static boolean isPermissionError(String message) {
String m = message.toLowerCase(Locale.ROOT);
return m.contains("insufficient") || m.contains("permission");
}
private void applyFilter() {
String needle = searchField.getText().trim().toLowerCase(Locale.ROOT);
boolean own = onlyOwn.isSelected();
String self = conn.getSelfUniqueId();
sorter.setRowFilter(new RowFilter<>() {
@Override
public boolean include(Entry<? extends BanTableModel, ? extends Integer> entry) {
BanEntry ban = tableModel.get(entry.getIdentifier());
if (own && !ban.createdBy(self)) return false;
if (needle.isEmpty()) return true;
for (Column c : Column.values()) {
if (tableModel.text(ban, c).toLowerCase(Locale.ROOT).contains(needle)) return true;
}
return false;
}
});
}
private List<BanEntry> selectedBans() {
List<BanEntry> selected = new ArrayList<>();
for (int row : table.getSelectedRows()) selected.add(tableModel.get(table.convertRowIndexToModel(row)));
return selected;
}
// ---- permissions ----
/**
* Whether we hold a permission. Until the server has named its permissions nothing can
* be resolved, so everything is offered and the server is left to refuse.
*/
private boolean can(String permission) {
ServerModel model = conn.getModel();
return !model.hasPermissionNames() || model.selfPermissionValue(permission) > 0;
}
private boolean canDelete(List<BanEntry> bans) {
if (can("b_client_ban_delete")) return true;
if (!can("b_client_ban_delete_own")) return false;
String self = conn.getSelfUniqueId();
return bans.stream().allMatch(b -> b.createdBy(self));
}
private void updateButtons() {
List<BanEntry> selected = selectedBans();
addButton.setEnabled(can("b_client_ban_create"));
removeButton.setEnabled(!selected.isEmpty() && canDelete(selected));
editButton.setEnabled(selected.size() == 1 && canDelete(selected) && can("b_client_ban_create"));
}
// ---- actions ----
private long maxBanSeconds() {
return conn.getModel().selfPermissionValue("i_client_ban_max_bantime");
}
private void addBan() {
if (!addButton.isEnabled()) return;
BanAddDialog dialog = new BanAddDialog(owner, presets, null, maxBanSeconds());
dialog.setVisible(true);
BanRule rule = dialog.getRule();
if (rule == null) return;
conn.addBan(rule, error -> afterChange(error, "Error adding ban: ",
() -> JOptionPane.showMessageDialog(this, "Your ban was successfully added.", "Ban List",
JOptionPane.INFORMATION_MESSAGE)));
}
private void editBan() {
if (!editButton.isEnabled()) return;
BanEntry ban = selectedBans().get(0);
BanAddDialog dialog = new BanAddDialog(owner, presets, ban, maxBanSeconds());
dialog.setVisible(true);
BanRule rule = dialog.getRule();
if (rule == null) return;
conn.replaceBan(ban.id, rule, error -> afterChange(error, "Error editing ban: ", null));
}
private void removeBans() {
if (!removeButton.isEnabled()) return;
List<BanEntry> bans = selectedBans();
int answer = JOptionPane.showConfirmDialog(this, "Do you really want to delete the selected ban(s)?",
"Delete bans", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE);
if (answer != JOptionPane.YES_OPTION) return;
List<Integer> ids = bans.stream().map(b -> b.id).collect(Collectors.toList());
conn.deleteBans(ids, error -> afterChange(error, "Error deleting ban: ", null));
}
/** Reports a failed change, or re-reads the list so the table shows what the server now holds. */
private void afterChange(String error, String errorPrefix, Runnable onSuccess) {
SwingUtilities.invokeLater(() -> {
if (!isDisplayable()) return;
if (error != null) {
JOptionPane.showMessageDialog(this, errorPrefix + error, "Ban List", JOptionPane.ERROR_MESSAGE);
} else if (onSuccess != null) {
onSuccess.run();
}
reload();
});
}
// ---- table ----
private final class BanTableModel extends AbstractTableModel {
private List<BanEntry> bans = List.of();
void setBans(List<BanEntry> bans) {
this.bans = new ArrayList<>(bans);
fireTableDataChanged();
}
BanEntry get(int row) {
return bans.get(row);
}
@Override
public int getRowCount() {
return bans.size();
}
@Override
public int getColumnCount() {
return Column.values().length;
}
@Override
public String getColumnName(int column) {
return Column.values()[column].title;
}
@Override
public Class<?> getColumnClass(int column) {
switch (Column.values()[column]) {
case CREATED:
case EXPIRES:
return Long.class;
case ENFORCED:
return Integer.class;
default:
return String.class;
}
}
/** Sortable values: the date columns sort by time, with "infinite" last. */
@Override
public Object getValueAt(int row, int column) {
BanEntry ban = bans.get(row);
switch (Column.values()[column]) {
case CREATED:
return ban.createdEpochSeconds;
case EXPIRES:
return ban.isPermanent() ? Long.MAX_VALUE : ban.expiresEpochSeconds();
case ENFORCED:
return ban.enforcements;
default:
return text(ban, Column.values()[column]);
}
}
/** What a cell shows (and what the search field matches). */
String text(BanEntry ban, Column column) {
switch (column) {
case TARGETS:
return targets(ban);
case NAME:
return ban.name;
case IP:
return ban.ip;
case UID:
return ban.uid;
case MYTSID:
return ban.mytsid;
case REASON:
return ban.reason;
case CREATOR:
return ban.invokerName;
case CREATED:
return date(ban.createdEpochSeconds);
case EXPIRES:
return ban.isPermanent() ? "infinite" : date(ban.expiresEpochSeconds());
case ENFORCED:
return Integer.toString(ban.enforcements);
default:
return "";
}
}
/** The official summary column: {@code name=…, ip=…, uid=… (last nickname), mytsid=…}. */
private String targets(BanEntry ban) {
List<String> parts = new ArrayList<>(4);
if (!ban.name.isEmpty()) parts.add("name=" + ban.name);
if (!ban.ip.isEmpty()) parts.add("ip=" + ban.ip);
if (!ban.uid.isEmpty()) {
parts.add("uid=" + ban.uid + (ban.lastNickname.isEmpty() ? "" : " (" + ban.lastNickname + ")"));
}
if (!ban.mytsid.isEmpty()) parts.add("mytsid=" + ban.mytsid);
return String.join(", ", parts);
}
}
private static String date(long epochSeconds) {
if (epochSeconds <= 0) return "";
return DATE.format(Instant.ofEpochSecond(epochSeconds).atZone(ZoneId.systemDefault()));
}
/** Shows the displayed text for every column and bolds our own bans when asked to. */
private final class BanCellRenderer extends DefaultTableCellRenderer {
@Override
public Component getTableCellRendererComponent(JTable t, Object value, boolean selected,
boolean focused, int row, int column) {
BanEntry ban = tableModel.get(t.convertRowIndexToModel(row));
Column c = Column.values()[t.convertColumnIndexToModel(column)];
super.getTableCellRendererComponent(t, tableModel.text(ban, c), selected, focused, row, column);
boolean bold = highlightOwn.isSelected() && ban.createdBy(conn.getSelfUniqueId());
setFont(bold ? Theme.uiBold() : Theme.uiFont());
setHorizontalAlignment(c == Column.ENFORCED ? RIGHT : LEFT);
return this;
}
}
/** A table whose rows carry the official client's tooltip of a ban's details. */
private final class BanTable extends JTable {
BanTable() {
super(tableModel);
}
@Override
public String getToolTipText(MouseEvent e) {
int row = rowAtPoint(e.getPoint());
if (row < 0) return null;
BanEntry ban = tableModel.get(convertRowIndexToModel(row));
return "<html><table>"
+ tooltipRow("Last name", ban.lastNickname)
+ tooltipRow("Creator", ban.invokerName)
+ tooltipRow("Created", date(ban.createdEpochSeconds))
+ tooltipRow("Expires", ban.isPermanent() ? "infinite" : date(ban.expiresEpochSeconds()))
+ tooltipRow("Enforced", Integer.toString(ban.enforcements))
+ "</table>";
}
}
private static String tooltipRow(String label, String value) {
return "<tr><td>" + label + ":</td><td style=\"font-weight:bold\"><nobr>" + escape(value)
+ "</nobr></td></tr>";
}
private static String escape(String s) {
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
/** A text field that shows "Search" while it is empty. */
private static final class SearchField extends javax.swing.JTextField {
SearchField() {
super(20);
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
if (!getText().isEmpty()) return;
Graphics2D g2 = (Graphics2D) g.create();
g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g2.setColor(Theme.chatEvent());
g2.setFont(getFont().deriveFont(Font.ITALIC));
int y = (getHeight() + g2.getFontMetrics().getAscent() - g2.getFontMetrics().getDescent()) / 2;
g2.drawString("Search", getInsets().left + 2, y);
g2.dispose();
}
}
}

View File

@@ -3,6 +3,7 @@ package com.ts3client.ui;
import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.desktop.DesktopAudioBackend;
import com.ts3client.config.AwayMessages;
import com.ts3client.config.BanReasonPresets;
import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks;
import com.ts3client.config.IdentityStore;
@@ -55,6 +56,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
/** The tab whose views are on screen. */
private ServerTab selected;
private BanReasonPresets banReasonPresets;
/** The tab that owns the capture device; null when nobody is capturing. */
private ServerTab micTab;
@@ -191,6 +193,16 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
showIdentities();
}
@Override
public boolean canViewBanList() {
return selected != null && selected.canViewBanList();
}
@Override
public void onShowBanList() {
if (selected != null) selected.showBanList(banReasonPresets());
}
@Override
public void onShowSettings() {
showSettings();
@@ -743,6 +755,12 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
System.exit(0);
}
/** Loaded the first time a ban dialog needs them, and shared by every tab from then on. */
private BanReasonPresets banReasonPresets() {
if (banReasonPresets == null) banReasonPresets = BanReasonPresets.load();
return banReasonPresets;
}
private void showIdentities() {
new IdentitiesDialog(this, identities, settings, bookmarks, null).setVisible(true);
}

View File

@@ -7,9 +7,12 @@ import javax.swing.JCheckBoxMenuItem;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JMenuItem;
import javax.swing.JPopupMenu;
import javax.swing.KeyStroke;
import javax.swing.MenuSelectionManager;
import javax.swing.SwingUtilities;
import javax.swing.event.MenuEvent;
import javax.swing.event.MenuListener;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
@@ -45,6 +48,11 @@ final class MainMenuBar extends JMenuBar {
void onShowIdentities();
/** Whether the current server lets us read its ban list; asked as the Tools menu opens. */
boolean canViewBanList();
void onShowBanList();
void onShowSettings();
void onShowAbout();
@@ -64,6 +72,7 @@ final class MainMenuBar extends JMenuBar {
private JCheckBoxMenuItem awayItem;
private JMenuItem awayStatusItem;
private JCheckBoxMenuItem commanderItem;
private JMenuItem banListItem;
MainMenuBar(Bookmarks bookmarks, Listener listener) {
this.bookmarks = bookmarks;
@@ -114,11 +123,34 @@ final class MainMenuBar extends JMenuBar {
JMenu tools = new JMenu("Tools");
JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER"));
identitiesItem.addActionListener(e -> listener.onShowIdentities());
banListItem = new JMenuItem("Ban List", Icons.of("BAN_LIST"));
banListItem.addActionListener(e -> listener.onShowBanList());
JMenuItem options = new JMenuItem("Options…", Icons.of("SETTINGS"));
options.addActionListener(e -> listener.onShowSettings());
JPopupMenu.Separator banListSeparator = new JPopupMenu.Separator();
tools.add(identitiesItem);
tools.addSeparator();
tools.add(banListItem);
tools.add(banListSeparator);
tools.add(options);
// Permissions arrive after the connection does, so the item is judged as the menu opens
// rather than on a fixed refresh.
tools.addMenuListener(new MenuListener() {
@Override
public void menuSelected(MenuEvent e) {
boolean shown = banListItem.isEnabled() && listener.canViewBanList();
banListItem.setVisible(shown);
banListSeparator.setVisible(shown);
}
@Override
public void menuDeselected(MenuEvent e) {
}
@Override
public void menuCanceled(MenuEvent e) {
}
});
JMenu help = new JMenu("Help");
JMenuItem about = new JMenuItem("About", Icons.of("ABOUT"));
@@ -137,6 +169,7 @@ final class MainMenuBar extends JMenuBar {
awayItem.setEnabled(connected);
awayStatusItem.setEnabled(connected);
commanderItem.setEnabled(connected);
banListItem.setEnabled(connected);
awayItem.setSelected(away);
commanderItem.setSelected(commander);
}

View File

@@ -3,9 +3,11 @@ package com.ts3client.ui;
import com.ts3client.audio.AudioBackend;
import com.ts3client.config.IdentityEntry;
import com.ts3client.config.IdentityStore;
import com.ts3client.config.BanReasonPresets;
import com.ts3client.config.Settings;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.ServerModel;
import com.ts3client.net.TeamspeakConnection;
import com.ts3client.sound.SoundNotifier;
import com.ts3client.text.TsLink;
@@ -58,6 +60,9 @@ final class ServerTab implements ServerTabConnectionEvents.Listener {
/** Identity used for the current connection, so it can be saved into a bookmark. */
private String identityId = "";
/** The one open ban list window for this server, if any. */
private BanListDialog banList;
ServerTab(MainFrame host, Settings settings, IdentityStore identities, AudioBackend audio,
SoundNotifier sounds) {
this.host = host;
@@ -266,6 +271,33 @@ final class ServerTab implements ServerTabConnectionEvents.Listener {
if (conn.isConnected()) conn.disconnect();
}
/**
* Whether the server grants us {@code b_client_ban_list}. Until it has named its
* permissions nothing can be resolved, so the list is offered and the server left to refuse.
*/
boolean canViewBanList() {
if (!conn.isConnected()) return false;
ServerModel model = conn.getModel();
return !model.hasPermissionNames() || model.selfPermissionValue("b_client_ban_list") > 0;
}
/** Opens the server's ban list, or brings the already open one forward. */
void showBanList(BanReasonPresets presets) {
if (!conn.isConnected()) return;
if (banList == null || !banList.isDisplayable()) {
banList = new BanListDialog(host, conn, settings, presets);
}
banList.setVisible(true);
banList.toFront();
}
/** The server's name changed or the connection went; the ban list window follows. */
void banListServerChanged(boolean connected) {
if (banList == null) return;
if (connected) banList.serverNameChanged();
else banList.connectionLost();
}
/** Releases the background resources of a tab that is being thrown away. */
void dispose() {
conn.getIcons().shutdown();

View File

@@ -94,6 +94,7 @@ final class ServerTabConnectionEvents implements ConnectionListener {
chatPanel.setServerName(null);
chatPanel.setChannelName(null);
chatPanel.appendSystem("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
tab.banListServerChanged(false);
host.tabDisconnected(tab);
});
}
@@ -108,6 +109,7 @@ final class ServerTabConnectionEvents implements ConnectionListener {
if (conn.isConnected() && name != null && !name.isBlank() && !name.equals(tab.title())) {
listener.setTitle(name);
host.tabUpdated(tab);
tab.banListServerChanged(true);
}
if (conn.isConnected()) {
chatPanel.setServerName(name);

View File

@@ -154,7 +154,8 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions {
@Override
public void banClient(ClientEntry client) {
if (!conn.isConnected()) return;
BanDialog dialog = new BanDialog(host, client.nickname);
BanDialog dialog = new BanDialog(host, client.nickname,
conn.getModel().selfPermissionValue("i_client_ban_max_bantime"));
dialog.setVisible(true);
if (dialog.isConfirmed()) conn.banClient(client.id, dialog.getSeconds(), dialog.getReason());
}