From 22e0304f254f95c51e3a44abdb7ff96a6883a829 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 17 Sep 2026 23:00:18 +0000 Subject: [PATCH] Add the Tools > Ban List window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ts3-client/README.md | 5 + .../ts3client/config/BanReasonPresets.java | 80 +++ .../java/com/ts3client/config/Settings.java | 8 + .../main/java/com/ts3client/net/BanAdmin.java | 115 +++ .../ts3client/net/ConnectionEventHandler.java | 1 + .../java/com/ts3client/net/ServerModel.java | 10 +- .../ts3client/net/TeamspeakConnection.java | 54 ++ .../java/com/ts3client/net/ban/BanEntry.java | 95 +++ .../com/ts3client/net/ban/BanPattern.java | 126 ++++ .../java/com/ts3client/net/ban/BanRule.java | 44 ++ .../com/ts3client/net/ban/BanPatternTest.java | 67 ++ .../java/com/ts3client/ui/BanAddDialog.java | 256 +++++++ .../main/java/com/ts3client/ui/BanDialog.java | 50 +- .../com/ts3client/ui/BanDurationPicker.java | 84 +++ .../java/com/ts3client/ui/BanListDialog.java | 666 ++++++++++++++++++ .../main/java/com/ts3client/ui/MainFrame.java | 18 + .../java/com/ts3client/ui/MainMenuBar.java | 33 + .../main/java/com/ts3client/ui/ServerTab.java | 32 + .../ui/ServerTabConnectionEvents.java | 2 + .../ts3client/ui/ServerTabTreeActions.java | 3 +- 20 files changed, 1705 insertions(+), 44 deletions(-) create mode 100644 ts3-client/core/src/main/java/com/ts3client/config/BanReasonPresets.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/BanAdmin.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/ban/BanEntry.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/ban/BanPattern.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/ban/BanRule.java create mode 100644 ts3-client/core/src/test/java/com/ts3client/net/ban/BanPatternTest.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/BanAddDialog.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/BanDurationPicker.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/BanListDialog.java diff --git a/ts3-client/README.md b/ts3-client/README.md index 19ed273..b8b9883 100644 --- a/ts3-client/README.md +++ b/ts3-client/README.md @@ -76,6 +76,11 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged. - **Identity management** (Tools → Identities) — keep several identities, mark one as the default, pick one per server or per bookmark, rename, raise an identity's security level, and import/export TeamSpeak-compatible `.ini` identity files. +- **Ban List** (Tools → Ban List) — the server's bans in a searchable table (Add / + Remove / Edit, own-ban filter and highlighting, choosable columns), with the + official Add dialog: IP and nickname patterns read as IPv4/IPv6 wildcards, fixed + strings or regular expressions, unique id, myTeamSpeak id, reason presets and a + duration capped by `i_client_ban_max_bantime`. - **Self status** — Away (with message) and Channel Commander toggles. - **Status bar** shows the server name, user count and live ping. - Change your nickname, mute/deafen from the toolbar. diff --git a/ts3-client/core/src/main/java/com/ts3client/config/BanReasonPresets.java b/ts3-client/core/src/main/java/com/ts3client/config/BanReasonPresets.java new file mode 100644 index 0000000..4e398a6 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/config/BanReasonPresets.java @@ -0,0 +1,80 @@ +package com.ts3client.config; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Properties; + +/** + * The reasons the ban dialogs offer as presets, stored alongside the settings file. + * Frontend-agnostic: no UI dependencies. + */ +public final class BanReasonPresets { + + private static final File DIR = new File(System.getProperty("user.home"), ".ts3jclient"); + private static final File FILE = new File(DIR, "banreasons.properties"); + + private final List entries = new ArrayList<>(); + + public List all() { + return Collections.unmodifiableList(entries); + } + + /** Remembers a reason, moving it to the front if it is already known. */ + public void add(String reason) { + if (reason == null || reason.isBlank()) return; + entries.remove(reason.trim()); + entries.add(0, reason.trim()); + } + + public void remove(String reason) { + entries.remove(reason); + } + + public static BanReasonPresets load() { + BanReasonPresets presets = new BanReasonPresets(); + if (!FILE.isFile()) return presets; + Properties p = new Properties(); + try (FileInputStream in = new FileInputStream(FILE)) { + p.load(in); + } catch (Exception e) { + return presets; + } + int count = parseInt(p.getProperty("count"), 0); + for (int i = 0; i < count; i++) { + String text = p.getProperty("reason." + i); + if (text != null && !text.isBlank()) presets.entries.add(text); + } + return presets; + } + + public void save() { + Properties p = new Properties(); + p.setProperty("count", Integer.toString(entries.size())); + for (int i = 0; i < entries.size(); i++) { + p.setProperty("reason." + i, entries.get(i)); + } + try { + if (!DIR.isDirectory()) { + //noinspection ResultOfMethodCallIgnored + DIR.mkdirs(); + } + try (FileOutputStream out = new FileOutputStream(FILE)) { + p.store(out, "TS3J client ban reason presets"); + } + } catch (Exception ignored) { + } + } + + private static int parseInt(String v, int def) { + if (v == null) return def; + try { + return Integer.parseInt(v.trim()); + } catch (NumberFormatException e) { + return def; + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java index 88fc1dc..0830de2 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java @@ -141,6 +141,10 @@ public final class Settings { public boolean showMasterVolumeSlider = true; /** Share of the tree/info column the tree takes, i.e. where their divider sits. */ public double infoSplitRatio = 0.68; + /** Whether the ban list shows the bans we created ourselves in bold. */ + public boolean banHighlightOwn = true; + /** The ban list columns the user shows, comma-separated column names; empty for the default set. */ + public String banListColumns = ""; /** Which actions make a sound, and which ones are important enough to survive muting. */ public final NotificationSettings notifications = new NotificationSettings(); @@ -233,6 +237,8 @@ public final class Settings { showStatusBar = parseB(props.getProperty("showStatusBar"), showStatusBar); showMasterVolumeSlider = parseB(props.getProperty("showMasterVolumeSlider"), showMasterVolumeSlider); infoSplitRatio = parseD(props.getProperty("infoSplitRatio"), infoSplitRatio); + banHighlightOwn = parseB(props.getProperty("banHighlightOwn"), banHighlightOwn); + banListColumns = props.getProperty("banListColumns", banListColumns); notifications.load(props); } @@ -276,6 +282,8 @@ public final class Settings { props.setProperty("showStatusBar", Boolean.toString(showStatusBar)); props.setProperty("showMasterVolumeSlider", Boolean.toString(showMasterVolumeSlider)); props.setProperty("infoSplitRatio", Double.toString(infoSplitRatio)); + props.setProperty("banHighlightOwn", Boolean.toString(banHighlightOwn)); + props.setProperty("banListColumns", banListColumns); notifications.store(props); } diff --git a/ts3-client/core/src/main/java/com/ts3client/net/BanAdmin.java b/ts3-client/core/src/main/java/com/ts3client/net/BanAdmin.java new file mode 100644 index 0000000..3a5bdbd --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/BanAdmin.java @@ -0,0 +1,115 @@ +package com.ts3client.net; + +import com.github.manevolent.ts3j.command.CommandException; +import com.github.manevolent.ts3j.command.SingleCommand; +import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter; +import com.github.manevolent.ts3j.event.UnknownTeamspeakEvent; +import com.github.manevolent.ts3j.protocol.ProtocolRole; +import com.ts3client.net.ban.BanEntry; +import com.ts3client.net.ban.BanRule; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * The server's ban list: reading it and adding, replacing and deleting entries. + * + *

Split out of {@link TeamspeakConnection} like {@link ChannelAdmin}, and for the same + * reason: it is a self-contained request/response corner of the protocol that only the + * ban list window uses. Every call here blocks and must run off the UI thread; the + * connection's public wrappers do that. + */ +final class BanAdmin { + + /** What {@code banlist} answers when there are no bans at all. */ + private static final int ERROR_DATABASE_EMPTY_RESULT = 0x0501; + + private final TeamspeakConnection conn; + + /** + * The {@code banlist} reply in progress. The server answers the command with a burst + * of {@code notifybanlist} events rather than a command response, so one request may + * be in flight at a time and its entries are collected here until the command itself + * completes. + */ + private volatile List listing; + + BanAdmin(TeamspeakConnection conn) { + this.conn = conn; + } + + /** Reads every ban on the server. */ + List list() throws Exception { + List entries = Collections.synchronizedList(new ArrayList<>()); + listing = entries; + try { + conn.socket().executeCommand(new SingleCommand("banlist", ProtocolRole.CLIENT)).complete(); + // The entries were queued on the event thread before the command's own reply + // was read, so draining that thread is what guarantees they have all landed. + conn.awaitEventsProcessed(); + } catch (CommandException e) { + if (e.getErrorId() != ERROR_DATABASE_EMPTY_RESULT) throw e; + } finally { + listing = null; + } + return new ArrayList<>(entries); + } + + /** Collects one {@code notifybanlist} entry for the request in flight. */ + void onListEntry(UnknownTeamspeakEvent e) { + List entries = listing; + if (entries == null) return; + BanEntry entry = BanEntry.from(e.getMap()); + if (entry.id >= 0) entries.add(entry); + } + + /** + * Adds a ban. + * + * @return the new ban's id, or -1 where the server did not name it + */ + int add(BanRule rule) throws Exception { + if (!rule.hasTarget()) throw new IllegalArgumentException("A ban needs something to match"); + SingleCommand cmd = new SingleCommand("banadd", ProtocolRole.CLIENT); + addIfSet(cmd, "ip", rule.ipRegex); + addIfSet(cmd, "name", rule.nameRegex); + addIfSet(cmd, "uid", rule.uid); + addIfSet(cmd, "mytsid", rule.mytsid); + if (!rule.isPermanent()) cmd.add(new CommandSingleParameter("time", Long.toString(rule.durationSeconds))); + addIfSet(cmd, "banreason", rule.reason); + for (SingleCommand answer : conn.socket().executeCommand(cmd).get()) { + Map fields = answer.toMap(); + if (fields.containsKey("banid")) { + return BanEntry.from(fields).id; + } + } + return -1; + } + + /** + * Replaces a ban with a re-written one. The server has no way to edit a ban in place, + * so the new one is added before the old is removed: a failure half-way leaves the + * client banned twice rather than not at all. + */ + void replace(int banId, BanRule rule) throws Exception { + add(rule); + try { + delete(banId); + } catch (Exception e) { + throw new IllegalStateException("The edited ban was added, but the original could not be" + + " removed: " + e.getMessage(), e); + } + } + + void delete(int banId) throws Exception { + SingleCommand cmd = new SingleCommand("bandel", ProtocolRole.CLIENT, + new CommandSingleParameter("banid", Integer.toString(banId))); + conn.socket().executeCommand(cmd).complete(); + } + + private static void addIfSet(SingleCommand cmd, String key, String value) { + if (value != null && !value.isEmpty()) cmd.add(new CommandSingleParameter(key, value)); + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java index 15f1d49..2d09367 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java @@ -601,6 +601,7 @@ final class ConnectionEventHandler implements TS3Listener { public void onUnknownEvent(UnknownTeamspeakEvent e) { if ("notifyconnectioninfo".equals(e.getCommand())) conn.stats.onReport(e); else if ("notifychannelpermlist".equals(e.getCommand())) conn.channels.onPermListEntry(e); + else if ("notifybanlist".equals(e.getCommand())) conn.bans.onListEntry(e); } // ---- helpers ---- diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java index 91673ca..84377cb 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java @@ -21,11 +21,13 @@ public final class ServerModel { /** * Permission id -> name, learned from the server's {@code permissionlist} response. * Entries carry no id of their own: TS3 numbers them by position in the response, - * and the empty "group_id_end" separator records marking category boundaries don't - * count toward that position, so only {@link #putPermissionName} advances it. + * starting at 1, and the empty "group_id_end" separator records marking category + * boundaries don't count toward that position, so only {@link #putPermissionName} + * advances it. */ private final Map permissionNames = new LinkedHashMap<>(); - private int nextPermissionId; + private static final int FIRST_PERMISSION_ID = 1; + private int nextPermissionId = FIRST_PERMISSION_ID; /** Permission id -> the local client's resolved value, from {@code notifyclientneededpermissions}. */ private final Map selfPermissionValues = new LinkedHashMap<>(); private String serverName = "TeamSpeak Server"; @@ -38,7 +40,7 @@ public final class ServerModel { serverGroups.clear(); channelGroups.clear(); permissionNames.clear(); - nextPermissionId = 0; + nextPermissionId = FIRST_PERMISSION_ID; selfPermissionValues.clear(); defaultChannelGroupId = 0; } 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 5a9c59a..7f2874d 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 @@ -19,6 +19,8 @@ import com.ts3client.chatlog.ChatLogStore; import com.ts3client.config.Settings; import com.ts3client.net.filetransfer.FileTransfer; import com.ts3client.net.filetransfer.FileTransferManager; +import com.ts3client.net.ban.BanEntry; +import com.ts3client.net.ban.BanRule; import com.ts3client.net.filetransfer.RemoteFile; import com.ts3client.sound.SoundEvent; import com.ts3client.sound.SoundNotifier; @@ -70,6 +72,8 @@ public final class TeamspeakConnection implements TS3Listener { final ConnectionStatsCollector stats = new ConnectionStatsCollector(this); /** Channel editing and the server's icon store; package-private for {@link ConnectionEventHandler}. */ final ChannelAdmin channels = new ChannelAdmin(this); + /** The server's ban list; also package-private for {@link ConnectionEventHandler}. */ + final BanAdmin bans = new BanAdmin(this); /** Package-private: read directly by {@link ConnectionStatsCollector}. */ LocalTeamspeakClientSocket client; @@ -951,6 +955,56 @@ public final class TeamspeakConnection implements TS3Listener { }); } + // ---- bans ---- + + /** Reads the server's ban list, delivering it (or a failure message) off the UI thread. */ + public void requestBanList(BiConsumer, String> callback) { + run("ts3j-ban-list", callback, bans::list); + } + + /** @param callback given {@code null} on success, or the failure message */ + public void addBan(BanRule rule, Consumer callback) { + run("ts3j-ban-add", (ignored, error) -> callback.accept(error), () -> { + bans.add(rule); + return null; + }); + } + + /** + * Rewrites an existing ban. + * + * @param callback given {@code null} on success, or the failure message + */ + public void replaceBan(int banId, BanRule rule, Consumer callback) { + run("ts3j-ban-edit", (ignored, error) -> callback.accept(error), () -> { + bans.replace(banId, rule); + return null; + }); + } + + /** + * Deletes bans one by one, stopping at the first the server refuses. + * + * @param callback given {@code null} on success, or the failure message + */ + public void deleteBans(Collection banIds, Consumer callback) { + run("ts3j-ban-delete", (ignored, error) -> callback.accept(error), () -> { + for (int id : banIds) bans.delete(id); + return null; + }); + } + + /** + * Our own unique identifier on this server, which is how the server names us as a + * ban's creator; empty while not connected. + */ + public String getSelfUniqueId() { + ClientEntry self = model.getClient(selfClientId); + if (self != null && !self.uniqueId.isEmpty()) return self.uniqueId; + LocalIdentity id = identity; + return id == null ? "" : id.getUid().toBase64(); + } + // ---- channel administration ---- /** diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ban/BanEntry.java b/ts3-client/core/src/main/java/com/ts3client/net/ban/BanEntry.java new file mode 100644 index 0000000..673eaf8 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/ban/BanEntry.java @@ -0,0 +1,95 @@ +package com.ts3client.net.ban; + +import java.util.Map; + +/** One entry of the server's ban list, as a {@code notifybanlist} event describes it. */ +public final class BanEntry { + + public final int id; + /** The regular expression matched against a joining client's IP, or empty. */ + public final String ip; + /** The regular expression matched against a joining client's nickname, or empty. */ + public final String name; + public final String uid; + public final String mytsid; + /** The nickname the banned client last used, where the server knew one. */ + public final String lastNickname; + public final long createdEpochSeconds; + /** How long the ban lasts from its creation, or 0 when it never expires. */ + public final long durationSeconds; + public final String invokerName; + public final int invokerDatabaseId; + public final String invokerUid; + public final String reason; + /** How many connection attempts the ban has blocked so far. */ + public final int enforcements; + + public BanEntry(int id, String ip, String name, String uid, String mytsid, String lastNickname, + long createdEpochSeconds, long durationSeconds, String invokerName, + int invokerDatabaseId, String invokerUid, String reason, int enforcements) { + this.id = id; + this.ip = orEmpty(ip); + this.name = orEmpty(name); + this.uid = orEmpty(uid); + this.mytsid = orEmpty(mytsid); + this.lastNickname = orEmpty(lastNickname); + this.createdEpochSeconds = createdEpochSeconds; + this.durationSeconds = Math.max(0, durationSeconds); + this.invokerName = orEmpty(invokerName); + this.invokerDatabaseId = invokerDatabaseId; + this.invokerUid = orEmpty(invokerUid); + this.reason = orEmpty(reason); + this.enforcements = enforcements; + } + + /** Reads an entry off a {@code notifybanlist} event's fields. */ + public static BanEntry from(Map fields) { + return new BanEntry( + parseInt(fields.get("banid"), -1), + fields.get("ip"), fields.get("name"), fields.get("uid"), fields.get("mytsid"), + fields.get("lastnickname"), + parseLong(fields.get("created"), 0), parseLong(fields.get("duration"), 0), + fields.get("invokername"), parseInt(fields.get("invokercldbid"), 0), + fields.get("invokeruid"), fields.get("reason"), + parseInt(fields.get("enforcements"), 0)); + } + + public boolean isPermanent() { + return durationSeconds == 0; + } + + /** When the ban lifts, in seconds since the epoch; meaningless for a permanent one. */ + public long expiresEpochSeconds() { + return createdEpochSeconds + durationSeconds; + } + + /** Whether {@code uid} (a client's unique identifier) created this ban. */ + public boolean createdBy(String uid) { + return uid != null && !uid.isEmpty() && uid.equals(invokerUid); + } + + /** The entry's targets and settings as a rule, which is what editing starts from. */ + public BanRule toRule() { + return new BanRule(ip, name, uid, mytsid, durationSeconds, reason); + } + + private static String orEmpty(String s) { + return s == null ? "" : s; + } + + private static int parseInt(String value, int fallback) { + try { + return value == null || value.isEmpty() ? fallback : Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private static long parseLong(String value, long fallback) { + try { + return value == null || value.isEmpty() ? fallback : Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + return fallback; + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ban/BanPattern.java b/ts3-client/core/src/main/java/com/ts3client/net/ban/BanPattern.java new file mode 100644 index 0000000..db5fb05 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/ban/BanPattern.java @@ -0,0 +1,126 @@ +package com.ts3client.net.ban; + +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * The notations a ban's IP and name may be typed in, and their translation to and from + * the regular expressions the server actually matches — the official client's + * "Interpret IP/Name as" choice. + * + *

A wildcard pattern is the text with {@code *} standing for a run of characters + * and everything else taken literally; a fixed string is taken literally throughout. + * In an IP a wildcard covers one address group (decimal for IPv4, hexadecimal for + * IPv6), in a name it covers anything. + */ +public enum BanPattern { + + WILDCARD_IPV4("Wildcard ipv4", "[0-9]+"), + WILDCARD_IPV6("Wildcard ipv6", "[0-9a-fA-F]+"), + FIXED_STRING("Fixed String", null), + REGULAR_EXPRESSION("Regular Expression", null); + + private static final String NAME_WILDCARD = ".*"; + private static final String META = "\\.[]{}()+?|^$*"; + + private final String label; + /** What one {@code *} in an IP stands for; {@code null} where the notation has no wildcards. */ + private final String ipWildcard; + + BanPattern(String label, String ipWildcard) { + this.label = label; + this.ipWildcard = ipWildcard; + } + + public String label() { + return label; + } + + /** The regular expression an IP written in this notation stands for. */ + public String ipToRegex(String text) { + return toRegex(text, ipWildcard); + } + + /** The regular expression a nickname written in this notation stands for. */ + public String nameToRegex(String text) { + return toRegex(text, ipWildcard == null ? null : NAME_WILDCARD); + } + + private String toRegex(String text, String wildcard) { + if (text == null) return ""; + if (this == REGULAR_EXPRESSION) return text; + StringBuilder out = new StringBuilder(text.length() + 8); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '*' && wildcard != null) out.append(wildcard); + else if (META.indexOf(c) >= 0) out.append('\\').append(c); + else out.append(c); + } + return out.toString(); + } + + /** + * Whether {@code regex} is something the server will accept as a pattern; an + * unparsable expression fails the whole {@code banadd}. + */ + public static boolean isValidRegex(String regex) { + if (regex == null || regex.isEmpty()) return true; + try { + Pattern.compile(regex); + return true; + } catch (PatternSyntaxException e) { + return false; + } + } + + /** A pattern written back in one of the friendlier notations, where that is possible. */ + public static final class Decoded { + public final BanPattern pattern; + public final String ip; + public final String name; + + Decoded(BanPattern pattern, String ip, String name) { + this.pattern = pattern; + this.ip = ip; + this.name = name; + } + } + + /** + * Finds the friendliest notation that reproduces an existing ban's IP and name + * expressions exactly, so an editor can show them the way they were most likely + * typed; anything it cannot round-trip is shown as the raw regular expression. + */ + public static Decoded decode(String ipRegex, String nameRegex) { + String ip = ipRegex == null ? "" : ipRegex; + String name = nameRegex == null ? "" : nameRegex; + for (BanPattern p : new BanPattern[] {WILDCARD_IPV4, WILDCARD_IPV6, FIXED_STRING}) { + String ipText = p.fromRegex(ip, p.ipWildcard); + String nameText = p.fromRegex(name, p.ipWildcard == null ? null : NAME_WILDCARD); + if (ipText != null && nameText != null + && p.ipToRegex(ipText).equals(ip) && p.nameToRegex(nameText).equals(name)) { + return new Decoded(p, ipText, nameText); + } + } + return new Decoded(REGULAR_EXPRESSION, ip, name); + } + + /** Inverts {@link #toRegex}; {@code null} where the expression is not in this notation. */ + private String fromRegex(String regex, String wildcard) { + StringBuilder out = new StringBuilder(regex.length()); + for (int i = 0; i < regex.length(); i++) { + if (wildcard != null && regex.startsWith(wildcard, i)) { + out.append('*'); + i += wildcard.length() - 1; + } else if (regex.charAt(i) == '\\') { + if (i + 1 >= regex.length() || META.indexOf(regex.charAt(i + 1)) < 0) return null; + out.append(regex.charAt(++i)); + } else if (META.indexOf(regex.charAt(i)) >= 0) { + return null; + } else { + out.append(regex.charAt(i)); + } + } + return out.toString(); + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ban/BanRule.java b/ts3-client/core/src/main/java/com/ts3client/net/ban/BanRule.java new file mode 100644 index 0000000..4532b72 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/ban/BanRule.java @@ -0,0 +1,44 @@ +package com.ts3client.net.ban; + +/** + * What a ban is made of, as {@code banadd} takes it: any combination of an IP pattern, + * a nickname pattern, a unique identifier and a myTeamSpeak id, with a duration and a + * reason. The patterns are regular expressions — see {@link BanPattern} for turning + * friendlier notations into them. + */ +public final class BanRule { + + /** The longest reason the server stores with a ban. */ + public static final int REASON_LIMIT = 80; + + public final String ipRegex; + public final String nameRegex; + public final String uid; + public final String mytsid; + /** The ban's length in seconds, or 0 for a permanent one. */ + public final long durationSeconds; + public final String reason; + + public BanRule(String ipRegex, String nameRegex, String uid, String mytsid, + long durationSeconds, String reason) { + this.ipRegex = trimmed(ipRegex); + this.nameRegex = trimmed(nameRegex); + this.uid = trimmed(uid); + this.mytsid = trimmed(mytsid); + this.durationSeconds = Math.max(0, durationSeconds); + this.reason = trimmed(reason); + } + + /** A rule with nothing to match bans nobody; the server rejects it. */ + public boolean hasTarget() { + return !ipRegex.isEmpty() || !nameRegex.isEmpty() || !uid.isEmpty() || !mytsid.isEmpty(); + } + + public boolean isPermanent() { + return durationSeconds == 0; + } + + private static String trimmed(String s) { + return s == null ? "" : s.trim(); + } +} diff --git a/ts3-client/core/src/test/java/com/ts3client/net/ban/BanPatternTest.java b/ts3-client/core/src/test/java/com/ts3client/net/ban/BanPatternTest.java new file mode 100644 index 0000000..34ea737 --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/net/ban/BanPatternTest.java @@ -0,0 +1,67 @@ +package com.ts3client.net.ban; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BanPatternTest { + + @Test + void wildcardIpv4TurnsStarsIntoAddressGroups() { + assertEquals("192\\.168\\.[0-9]+\\.[0-9]+", BanPattern.WILDCARD_IPV4.ipToRegex("192.168.*.*")); + assertEquals("2001:db8:[0-9a-fA-F]+:[0-9a-fA-F]+", BanPattern.WILDCARD_IPV6.ipToRegex("2001:db8:*:*")); + } + + @Test + void wildcardNamesMatchAnything() { + assertEquals("Troll.*", BanPattern.WILDCARD_IPV4.nameToRegex("Troll*")); + assertEquals("\\[Bot\\].*", BanPattern.WILDCARD_IPV6.nameToRegex("[Bot]*")); + } + + @Test + void fixedStringsAreTakenLiterally() { + assertEquals("10\\.0\\.0\\.1", BanPattern.FIXED_STRING.ipToRegex("10.0.0.1")); + assertEquals("a\\*b\\?\\(c\\)\\$\\^\\|\\\\", BanPattern.FIXED_STRING.nameToRegex("a*b?(c)$^|\\")); + } + + @Test + void regularExpressionsPassThrough() { + assertEquals("^Troll[0-9]{2}$", BanPattern.REGULAR_EXPRESSION.nameToRegex("^Troll[0-9]{2}$")); + } + + @Test + void decodeRecoversTheNotationThatProducedTheExpression() { + BanPattern.Decoded d = BanPattern.decode("192\\.168\\.[0-9]+\\.[0-9]+", "Troll.*"); + assertEquals(BanPattern.WILDCARD_IPV4, d.pattern); + assertEquals("192.168.*.*", d.ip); + assertEquals("Troll*", d.name); + + d = BanPattern.decode("fe80:[0-9a-fA-F]+", ""); + assertEquals(BanPattern.WILDCARD_IPV6, d.pattern); + assertEquals("fe80:*", d.ip); + + d = BanPattern.decode("", "a\\*b"); + assertEquals(BanPattern.FIXED_STRING, d.pattern); + assertEquals("a*b", d.name); + + d = BanPattern.decode("", "^Troll[0-9]{2}$"); + assertEquals(BanPattern.REGULAR_EXPRESSION, d.pattern); + assertEquals("^Troll[0-9]{2}$", d.name); + } + + @Test + void plainAddressesDecodeAsWildcardsLikeTheDialogDefault() { + BanPattern.Decoded d = BanPattern.decode("10\\.0\\.0\\.1", ""); + assertEquals(BanPattern.WILDCARD_IPV4, d.pattern); + assertEquals("10.0.0.1", d.ip); + } + + @Test + void validityFollowsJavaRegexSyntax() { + assertTrue(BanPattern.isValidRegex("")); + assertTrue(BanPattern.isValidRegex("^a.*$")); + assertFalse(BanPattern.isValidRegex("(unclosed")); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/BanAddDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/BanAddDialog.java new file mode 100644 index 0000000..cd6de71 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/BanAddDialog.java @@ -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. + * + *

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 patternBox = new JComboBox<>(BanPattern.values()); + private final JTextField uidField = new JTextField(30); + private final JTextField mytsidField = new JTextField(30); + private final JComboBox 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; + } + } +} 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 index e58c45d..e776518 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/BanDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/BanDialog.java @@ -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 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(); } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/BanDurationPicker.java b/ts3-client/swing/src/main/java/com/ts3client/ui/BanDurationPicker.java new file mode 100644 index 0000000..1650ed2 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/BanDurationPicker.java @@ -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 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; + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/BanListDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/BanListDialog.java new file mode 100644 index 0000000..226e72d --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/BanListDialog.java @@ -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 > 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. + * + *

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 sorter = new TableRowSorter<>(tableModel); + /** Every column the table can show, in model order, whether currently shown or not. */ + private final List allColumns = new ArrayList<>(); + private final Set 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("Add new ban
(Ctrl+N)"); + removeButton.setToolTipText("Remove selected bans
(Delete)"); + editButton.setToolTipText("Edit selected ban
(Return)"); + searchField.setToolTipText("Search bans by text
(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 savedColumns() { + Set 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 - " 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 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 selectedBans() { + List 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 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 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 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 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 bans = List.of(); + + void setBans(List 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 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 "" + + 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)) + + "
"; + } + } + + private static String tooltipRow(String label, String value) { + return "" + label + ":" + escape(value) + + ""; + } + + private static String escape(String s) { + return s.replace("&", "&").replace("<", "<").replace(">", ">"); + } + + /** 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(); + } + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index 5bd66c4..5d8d1a9 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -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); } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java index 4bee088..238da5f 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java @@ -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); } 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 f9149c6..9cb7e2e 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 @@ -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(); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java index 9048b38..896f0d5 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java @@ -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); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java index 4ee5eff..c3f6155 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java @@ -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()); }