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

@@ -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 - **Identity management** (Tools → Identities) — keep several identities, mark one
as the default, pick one per server or per bookmark, rename, raise an identity's 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. 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. - **Self status** — Away (with message) and Channel Commander toggles.
- **Status bar** shows the server name, user count and live ping. - **Status bar** shows the server name, user count and live ping.
- Change your nickname, mute/deafen from the toolbar. - Change your nickname, mute/deafen from the toolbar.

View File

@@ -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<String> entries = new ArrayList<>();
public List<String> 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;
}
}
}

View File

@@ -141,6 +141,10 @@ public final class Settings {
public boolean showMasterVolumeSlider = true; public boolean showMasterVolumeSlider = true;
/** Share of the tree/info column the tree takes, i.e. where their divider sits. */ /** Share of the tree/info column the tree takes, i.e. where their divider sits. */
public double infoSplitRatio = 0.68; 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. */ /** Which actions make a sound, and which ones are important enough to survive muting. */
public final NotificationSettings notifications = new NotificationSettings(); public final NotificationSettings notifications = new NotificationSettings();
@@ -233,6 +237,8 @@ public final class Settings {
showStatusBar = parseB(props.getProperty("showStatusBar"), showStatusBar); showStatusBar = parseB(props.getProperty("showStatusBar"), showStatusBar);
showMasterVolumeSlider = parseB(props.getProperty("showMasterVolumeSlider"), showMasterVolumeSlider); showMasterVolumeSlider = parseB(props.getProperty("showMasterVolumeSlider"), showMasterVolumeSlider);
infoSplitRatio = parseD(props.getProperty("infoSplitRatio"), infoSplitRatio); infoSplitRatio = parseD(props.getProperty("infoSplitRatio"), infoSplitRatio);
banHighlightOwn = parseB(props.getProperty("banHighlightOwn"), banHighlightOwn);
banListColumns = props.getProperty("banListColumns", banListColumns);
notifications.load(props); notifications.load(props);
} }
@@ -276,6 +282,8 @@ public final class Settings {
props.setProperty("showStatusBar", Boolean.toString(showStatusBar)); props.setProperty("showStatusBar", Boolean.toString(showStatusBar));
props.setProperty("showMasterVolumeSlider", Boolean.toString(showMasterVolumeSlider)); props.setProperty("showMasterVolumeSlider", Boolean.toString(showMasterVolumeSlider));
props.setProperty("infoSplitRatio", Double.toString(infoSplitRatio)); props.setProperty("infoSplitRatio", Double.toString(infoSplitRatio));
props.setProperty("banHighlightOwn", Boolean.toString(banHighlightOwn));
props.setProperty("banListColumns", banListColumns);
notifications.store(props); notifications.store(props);
} }

View File

@@ -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.
*
* <p>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<BanEntry> listing;
BanAdmin(TeamspeakConnection conn) {
this.conn = conn;
}
/** Reads every ban on the server. */
List<BanEntry> list() throws Exception {
List<BanEntry> 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<BanEntry> 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<String, String> 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));
}
}

View File

@@ -601,6 +601,7 @@ final class ConnectionEventHandler implements TS3Listener {
public void onUnknownEvent(UnknownTeamspeakEvent e) { public void onUnknownEvent(UnknownTeamspeakEvent e) {
if ("notifyconnectioninfo".equals(e.getCommand())) conn.stats.onReport(e); if ("notifyconnectioninfo".equals(e.getCommand())) conn.stats.onReport(e);
else if ("notifychannelpermlist".equals(e.getCommand())) conn.channels.onPermListEntry(e); else if ("notifychannelpermlist".equals(e.getCommand())) conn.channels.onPermListEntry(e);
else if ("notifybanlist".equals(e.getCommand())) conn.bans.onListEntry(e);
} }
// ---- helpers ---- // ---- helpers ----

View File

@@ -21,11 +21,13 @@ public final class ServerModel {
/** /**
* Permission id -> name, learned from the server's {@code permissionlist} response. * 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, * 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 * starting at 1, and the empty "group_id_end" separator records marking category
* count toward that position, so only {@link #putPermissionName} advances it. * boundaries don't count toward that position, so only {@link #putPermissionName}
* advances it.
*/ */
private final Map<Integer, String> permissionNames = new LinkedHashMap<>(); private final Map<Integer, String> 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}. */ /** Permission id -> the local client's resolved value, from {@code notifyclientneededpermissions}. */
private final Map<Integer, Integer> selfPermissionValues = new LinkedHashMap<>(); private final Map<Integer, Integer> selfPermissionValues = new LinkedHashMap<>();
private String serverName = "TeamSpeak Server"; private String serverName = "TeamSpeak Server";
@@ -38,7 +40,7 @@ public final class ServerModel {
serverGroups.clear(); serverGroups.clear();
channelGroups.clear(); channelGroups.clear();
permissionNames.clear(); permissionNames.clear();
nextPermissionId = 0; nextPermissionId = FIRST_PERMISSION_ID;
selfPermissionValues.clear(); selfPermissionValues.clear();
defaultChannelGroupId = 0; defaultChannelGroupId = 0;
} }

View File

@@ -19,6 +19,8 @@ import com.ts3client.chatlog.ChatLogStore;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import com.ts3client.net.filetransfer.FileTransfer; import com.ts3client.net.filetransfer.FileTransfer;
import com.ts3client.net.filetransfer.FileTransferManager; 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.net.filetransfer.RemoteFile;
import com.ts3client.sound.SoundEvent; import com.ts3client.sound.SoundEvent;
import com.ts3client.sound.SoundNotifier; import com.ts3client.sound.SoundNotifier;
@@ -70,6 +72,8 @@ public final class TeamspeakConnection implements TS3Listener {
final ConnectionStatsCollector stats = new ConnectionStatsCollector(this); final ConnectionStatsCollector stats = new ConnectionStatsCollector(this);
/** Channel editing and the server's icon store; package-private for {@link ConnectionEventHandler}. */ /** Channel editing and the server's icon store; package-private for {@link ConnectionEventHandler}. */
final ChannelAdmin channels = new ChannelAdmin(this); 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}. */ /** Package-private: read directly by {@link ConnectionStatsCollector}. */
LocalTeamspeakClientSocket client; 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<List<BanEntry>, 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<String> 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<String> 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<Integer> banIds, Consumer<String> 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 ---- // ---- channel administration ----
/** /**

View File

@@ -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<String, String> 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;
}
}
}

View File

@@ -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.
*
* <p>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();
}
}

View File

@@ -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();
}
}

View File

@@ -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"));
}
}

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; package com.ts3client.ui;
import com.ts3client.net.ban.BanRule;
import javax.swing.BorderFactory; import javax.swing.BorderFactory;
import javax.swing.Box; import javax.swing.Box;
import javax.swing.BoxLayout; import javax.swing.BoxLayout;
import javax.swing.JButton; import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JDialog; import javax.swing.JDialog;
import javax.swing.JPanel; import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.JTextField; import javax.swing.JTextField;
import javax.swing.SpinnerNumberModel;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Frame; import java.awt.Frame;
/** /**
@@ -20,27 +18,17 @@ import java.awt.Frame;
*/ */
final class BanDialog extends JDialog { final class BanDialog extends JDialog {
/** The longest reason the protocol carries with a ban. */ private final JTextField reasonField = ReasonDialog.reasonField(BanRule.REASON_LIMIT);
private static final int REASON_LIMIT = 80; private final BanDurationPicker duration;
/**
* The duration units the dialog offers, with the seconds each one is worth.
* The last entry is the permanent ban, which has no length and so takes no amount.
*/
private static final String[] UNIT_NAMES = {"Seconds", "Minutes", "Hours", "Days", "Permanent"};
private static final long[] UNIT_SECONDS = {1, 60, 3600, 86400, 0};
private final JTextField reasonField = ReasonDialog.reasonField(REASON_LIMIT);
private final JSpinner amount = new JSpinner(new SpinnerNumberModel(30, 1, 999999, 1));
private final JComboBox<String> unit = new JComboBox<>(UNIT_NAMES);
private boolean confirmed; 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); super(owner, "Ban Client", true);
duration = new BanDurationPicker(maxSeconds);
unit.setSelectedIndex(1); // minutes
unit.addActionListener(e -> amount.setEnabled(!isPermanent()));
JPanel form = new JPanel(); JPanel form = new JPanel();
form.setLayout(new BoxLayout(form, BoxLayout.Y_AXIS)); form.setLayout(new BoxLayout(form, BoxLayout.Y_AXIS));
@@ -51,7 +39,7 @@ final class BanDialog extends JDialog {
form.add(reasonField); form.add(reasonField);
form.add(Box.createVerticalStrut(8)); form.add(Box.createVerticalStrut(8));
form.add(ReasonDialog.label("Duration:")); form.add(ReasonDialog.label("Duration:"));
form.add(durationRow()); form.add(duration);
getContentPane().setLayout(new BorderLayout()); getContentPane().setLayout(new BorderLayout());
getContentPane().add(form, BorderLayout.CENTER); getContentPane().add(form, BorderLayout.CENTER);
@@ -65,18 +53,6 @@ final class BanDialog extends JDialog {
setLocationRelativeTo(owner); 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() { private JPanel buttons() {
JPanel panel = new JPanel(new BorderLayout()); JPanel panel = new JPanel(new BorderLayout());
JPanel right = new JPanel(); JPanel right = new JPanel();
@@ -94,10 +70,6 @@ final class BanDialog extends JDialog {
return panel; return panel;
} }
private boolean isPermanent() {
return UNIT_SECONDS[unit.getSelectedIndex()] == 0;
}
boolean isConfirmed() { boolean isConfirmed() {
return confirmed; return confirmed;
} }
@@ -108,6 +80,6 @@ final class BanDialog extends JDialog {
/** The ban's length in seconds, or 0 for a permanent ban. */ /** The ban's length in seconds, or 0 for a permanent ban. */
long getSeconds() { 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.AudioBackend;
import com.ts3client.audio.desktop.DesktopAudioBackend; import com.ts3client.audio.desktop.DesktopAudioBackend;
import com.ts3client.config.AwayMessages; import com.ts3client.config.AwayMessages;
import com.ts3client.config.BanReasonPresets;
import com.ts3client.config.Bookmark; import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks; import com.ts3client.config.Bookmarks;
import com.ts3client.config.IdentityStore; 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. */ /** The tab whose views are on screen. */
private ServerTab selected; private ServerTab selected;
private BanReasonPresets banReasonPresets;
/** The tab that owns the capture device; null when nobody is capturing. */ /** The tab that owns the capture device; null when nobody is capturing. */
private ServerTab micTab; private ServerTab micTab;
@@ -191,6 +193,16 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
showIdentities(); showIdentities();
} }
@Override
public boolean canViewBanList() {
return selected != null && selected.canViewBanList();
}
@Override
public void onShowBanList() {
if (selected != null) selected.showBanList(banReasonPresets());
}
@Override @Override
public void onShowSettings() { public void onShowSettings() {
showSettings(); showSettings();
@@ -743,6 +755,12 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
System.exit(0); 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() { private void showIdentities() {
new IdentitiesDialog(this, identities, settings, bookmarks, null).setVisible(true); 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.JMenu;
import javax.swing.JMenuBar; import javax.swing.JMenuBar;
import javax.swing.JMenuItem; import javax.swing.JMenuItem;
import javax.swing.JPopupMenu;
import javax.swing.KeyStroke; import javax.swing.KeyStroke;
import javax.swing.MenuSelectionManager; import javax.swing.MenuSelectionManager;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
import javax.swing.event.MenuEvent;
import javax.swing.event.MenuListener;
import java.awt.event.MouseAdapter; import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent; import java.awt.event.MouseEvent;
@@ -45,6 +48,11 @@ final class MainMenuBar extends JMenuBar {
void onShowIdentities(); 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 onShowSettings();
void onShowAbout(); void onShowAbout();
@@ -64,6 +72,7 @@ final class MainMenuBar extends JMenuBar {
private JCheckBoxMenuItem awayItem; private JCheckBoxMenuItem awayItem;
private JMenuItem awayStatusItem; private JMenuItem awayStatusItem;
private JCheckBoxMenuItem commanderItem; private JCheckBoxMenuItem commanderItem;
private JMenuItem banListItem;
MainMenuBar(Bookmarks bookmarks, Listener listener) { MainMenuBar(Bookmarks bookmarks, Listener listener) {
this.bookmarks = bookmarks; this.bookmarks = bookmarks;
@@ -114,11 +123,34 @@ final class MainMenuBar extends JMenuBar {
JMenu tools = new JMenu("Tools"); JMenu tools = new JMenu("Tools");
JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER")); JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER"));
identitiesItem.addActionListener(e -> listener.onShowIdentities()); 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")); JMenuItem options = new JMenuItem("Options…", Icons.of("SETTINGS"));
options.addActionListener(e -> listener.onShowSettings()); options.addActionListener(e -> listener.onShowSettings());
JPopupMenu.Separator banListSeparator = new JPopupMenu.Separator();
tools.add(identitiesItem); tools.add(identitiesItem);
tools.addSeparator(); tools.addSeparator();
tools.add(banListItem);
tools.add(banListSeparator);
tools.add(options); 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"); JMenu help = new JMenu("Help");
JMenuItem about = new JMenuItem("About", Icons.of("ABOUT")); JMenuItem about = new JMenuItem("About", Icons.of("ABOUT"));
@@ -137,6 +169,7 @@ final class MainMenuBar extends JMenuBar {
awayItem.setEnabled(connected); awayItem.setEnabled(connected);
awayStatusItem.setEnabled(connected); awayStatusItem.setEnabled(connected);
commanderItem.setEnabled(connected); commanderItem.setEnabled(connected);
banListItem.setEnabled(connected);
awayItem.setSelected(away); awayItem.setSelected(away);
commanderItem.setSelected(commander); commanderItem.setSelected(commander);
} }

View File

@@ -3,9 +3,11 @@ package com.ts3client.ui;
import com.ts3client.audio.AudioBackend; import com.ts3client.audio.AudioBackend;
import com.ts3client.config.IdentityEntry; import com.ts3client.config.IdentityEntry;
import com.ts3client.config.IdentityStore; import com.ts3client.config.IdentityStore;
import com.ts3client.config.BanReasonPresets;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry; import com.ts3client.net.ClientEntry;
import com.ts3client.net.ServerModel;
import com.ts3client.net.TeamspeakConnection; import com.ts3client.net.TeamspeakConnection;
import com.ts3client.sound.SoundNotifier; import com.ts3client.sound.SoundNotifier;
import com.ts3client.text.TsLink; 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. */ /** Identity used for the current connection, so it can be saved into a bookmark. */
private String identityId = ""; 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, ServerTab(MainFrame host, Settings settings, IdentityStore identities, AudioBackend audio,
SoundNotifier sounds) { SoundNotifier sounds) {
this.host = host; this.host = host;
@@ -266,6 +271,33 @@ final class ServerTab implements ServerTabConnectionEvents.Listener {
if (conn.isConnected()) conn.disconnect(); 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. */ /** Releases the background resources of a tab that is being thrown away. */
void dispose() { void dispose() {
conn.getIcons().shutdown(); conn.getIcons().shutdown();

View File

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

View File

@@ -154,7 +154,8 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions {
@Override @Override
public void banClient(ClientEntry client) { public void banClient(ClientEntry client) {
if (!conn.isConnected()) return; 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); dialog.setVisible(true);
if (dialog.isConfirmed()) conn.banClient(client.id, dialog.getSeconds(), dialog.getReason()); if (dialog.isConfirmed()) conn.banClient(client.id, dialog.getSeconds(), dialog.getReason());
} }