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:
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
115
ts3-client/core/src/main/java/com/ts3client/net/BanAdmin.java
Normal file
115
ts3-client/core/src/main/java/com/ts3client/net/BanAdmin.java
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ----
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ----
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <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("&", "&").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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user