diff --git a/ts3-client/README.md b/ts3-client/README.md index b8b9883..f151276 100644 --- a/ts3-client/README.md +++ b/ts3-client/README.md @@ -76,6 +76,20 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged. - **Identity management** (Tools → Identities) — keep several identities, mark one as the default, pick one per server or per bookmark, rename, raise an identity's security level, and import/export TeamSpeak-compatible `.ini` identity files. +- **Contacts** (Tools → Contacts) — TeamSpeak's friend/blocked list: mark a client as + **Friend**, **Blocked** or **Neutral** from its context menu (or drag it into the + window), give it a custom and a phonetic nickname, choose how the name is shown + (custom name and nickname / only custom / only nickname), and per contact **mute + automatically**, **ignore** server/channel chat, private chat, pokes and away + messages, and **allow or deny whispers**. Friends are coloured green and blocked + clients red in the tree (optional), per-type defaults for new contacts are + configurable ("Set Defaults"), and the global whisper policy lives in Options → + Contacts. The list is stored in `~/.ts3jclient/contacts.txt` in the *exact* record + format the official client keeps in the `Contacts` table of its `settings.db`; on + first run it is seeded from an installed TeamSpeak 3 client's database (read with a + small built-in SQLite reader — no driver needed), and can be re-imported any time. +- **Per-client volume** ("Change Volume…") — a dB gain on top of the master volume, + remembered in the client's contact entry. - **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 diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/VoiceOutput.java b/ts3-client/core/src/main/java/com/ts3client/audio/VoiceOutput.java index c5ebe1d..cb76ebb 100644 --- a/ts3-client/core/src/main/java/com/ts3client/audio/VoiceOutput.java +++ b/ts3-client/core/src/main/java/com/ts3client/audio/VoiceOutput.java @@ -27,6 +27,11 @@ public interface VoiceOutput { boolean isClientMuted(int clientId); + /** Scales one client's voice on top of the master volume; 1.0 is unchanged, the default. */ + void setClientVolume(int clientId, double gain); + + double getClientVolume(int clientId); + void setOutputDevice(String device); void removeClient(int clientId); diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java index 0830de2..f67a624 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java @@ -1,5 +1,6 @@ package com.ts3client.config; +import com.ts3client.contacts.ContactDefaults; import com.ts3client.sound.NotificationSettings; import java.io.File; @@ -43,6 +44,26 @@ public final class Settings { HYBRID } + /** Who may whisper to us, the way TeamSpeak's whisper options put it. */ + public enum WhisperPolicy { + /** Use individual settings in contacts, else allow. */ + ALLOW_UNLESS_DENIED("Use individual settings in contacts, else allow"), + /** Use individual settings in contacts, else deny. */ + DENY_UNLESS_ALLOWED("Use individual settings in contacts, else deny"), + /** Nobody is allowed to whisper to you; individual contact settings are ignored. */ + NOBODY("Nobody"); + + private final String label; + + WhisperPolicy(String label) { + this.label = label; + } + + public String label() { + return label; + } + } + private static final File DIR = new File(System.getProperty("user.home"), ".ts3jclient"); private static final File FILE = new File(DIR, "settings.properties"); @@ -149,6 +170,14 @@ public final class Settings { /** Which actions make a sound, and which ones are important enough to survive muting. */ public final NotificationSettings notifications = new NotificationSettings(); + // ---- contacts ---- + /** What a client starts out with when added to the contacts as a friend, blocked or neutral. */ + public final ContactDefaults contactDefaults = new ContactDefaults(); + /** Colour friends and blocked clients in the server tree, as the TeamSpeak client does. */ + public boolean colorContacts = true; + /** Whose whispers are played. */ + public WhisperPolicy whisperPolicy = WhisperPolicy.ALLOW_UNLESS_DENIED; + public static Settings load() { Settings s = new Settings(); try { @@ -240,6 +269,9 @@ public final class Settings { banHighlightOwn = parseB(props.getProperty("banHighlightOwn"), banHighlightOwn); banListColumns = props.getProperty("banListColumns", banListColumns); notifications.load(props); + contactDefaults.load(props); + colorContacts = parseB(props.getProperty("colorContacts"), colorContacts); + whisperPolicy = parseEnum(WhisperPolicy.class, props.getProperty("whisperPolicy"), whisperPolicy); } private void writeToProps() { @@ -285,6 +317,18 @@ public final class Settings { props.setProperty("banHighlightOwn", Boolean.toString(banHighlightOwn)); props.setProperty("banListColumns", banListColumns); notifications.store(props); + contactDefaults.store(props); + props.setProperty("colorContacts", Boolean.toString(colorContacts)); + props.setProperty("whisperPolicy", whisperPolicy.name()); + } + + private static > E parseEnum(Class type, String v, E def) { + if (v == null) return def; + try { + return Enum.valueOf(type, v.trim()); + } catch (IllegalArgumentException e) { + return def; + } } private static InputMode parseMode(String v, InputMode def) { diff --git a/ts3-client/core/src/main/java/com/ts3client/contacts/Contact.java b/ts3-client/core/src/main/java/com/ts3client/contacts/Contact.java new file mode 100644 index 0000000..395d1cd --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/contacts/Contact.java @@ -0,0 +1,199 @@ +package com.ts3client.contacts; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +/** + * One entry of the contact list, keyed by the client's unique id. + * + *

The fields are exactly the ones the TeamSpeak 3 client keeps for a contact, + * and {@link #toRecord()} / {@link #parseRecord} use its own {@code Key=Value} + * serialisation (the {@code value} column of its {@code Contacts} table), so + * records can be exchanged with it as they are. + */ +public final class Contact { + + /** TeamSpeak writes {@code LastSeen} as an ISO local date-time without zone. */ + private static final DateTimeFormatter LAST_SEEN = DateTimeFormatter.ISO_LOCAL_DATE_TIME; + + public final String uniqueId; + /** Custom name; TeamSpeak seeds it with the nickname the client had when added. */ + public String nickname = ""; + public String phoneticNickname = ""; + public ContactType type = ContactType.NEUTRAL; + public boolean automute; + public boolean ignorePublicMessages; + public boolean ignorePrivateMessages; + public boolean ignorePokes; + public boolean ignoreAvatar; + public boolean ignoreAwayMessage; + public NicknameDisplay nicknameDisplay = NicknameDisplay.BOTH; + public boolean hasVolumeModifier; + /** Playback gain for this client in dB, applied when {@link #hasVolumeModifier} is set. */ + public double volumeModifier; + public boolean whisperAllowed = true; + /** When the contact was last seen online, or {@code null} for never. */ + public LocalDateTime lastSeen; + public String lastSeenServerName = ""; + public String lastSeenServerAddress = ""; + + public Contact(String uniqueId) { + this.uniqueId = uniqueId == null ? "" : uniqueId; + } + + /** The name the contact is shown under, given the nickname it currently carries. */ + public String displayName(String currentNickname) { + return nicknameDisplay.render(nickname, currentNickname); + } + + /** Playback gain as a linear multiplier, 1.0 when no modifier is set. */ + public double volumeGain() { + return hasVolumeModifier ? Math.pow(10.0, volumeModifier / 20.0) : 1.0; + } + + public Contact copy() { + Contact c = new Contact(uniqueId); + c.copyFrom(this); + return c; + } + + /** Takes over every field but the unique id. */ + public void copyFrom(Contact other) { + nickname = other.nickname; + phoneticNickname = other.phoneticNickname; + type = other.type; + automute = other.automute; + ignorePublicMessages = other.ignorePublicMessages; + ignorePrivateMessages = other.ignorePrivateMessages; + ignorePokes = other.ignorePokes; + ignoreAvatar = other.ignoreAvatar; + ignoreAwayMessage = other.ignoreAwayMessage; + nicknameDisplay = other.nicknameDisplay; + hasVolumeModifier = other.hasVolumeModifier; + volumeModifier = other.volumeModifier; + whisperAllowed = other.whisperAllowed; + lastSeen = other.lastSeen; + lastSeenServerName = other.lastSeenServerName; + lastSeenServerAddress = other.lastSeenServerAddress; + } + + /** Applies the per-type defaults, as done when a client is first added. */ + public void applyDefaults(ContactDefaults.Entry defaults) { + nicknameDisplay = defaults.nicknameDisplay; + automute = defaults.automute; + ignorePublicMessages = defaults.ignorePublicMessages; + ignorePrivateMessages = defaults.ignorePrivateMessages; + ignorePokes = defaults.ignorePokes; + ignoreAvatar = defaults.ignoreAvatar; + ignoreAwayMessage = defaults.ignoreAwayMessage; + whisperAllowed = !defaults.ignoreWhisper; + } + + // ---- TeamSpeak record format ---- + + /** The contact as TeamSpeak's {@code Key=Value} lines, in the order it writes them. */ + public String toRecord() { + StringBuilder sb = new StringBuilder(); + line(sb, "IDS", uniqueId); + line(sb, "Nickname", nickname); + line(sb, "Friend", Integer.toString(type.code())); + line(sb, "Automute", flag(automute)); + line(sb, "IgnorePublicMessages", flag(ignorePublicMessages)); + line(sb, "IgnorePrivateMessages", flag(ignorePrivateMessages)); + line(sb, "IgnorePokes", flag(ignorePokes)); + line(sb, "IgnoreAvatar", flag(ignoreAvatar)); + line(sb, "IgnoreAwayMessage", flag(ignoreAwayMessage)); + line(sb, "NickShowType", Integer.toString(nicknameDisplay.code())); + line(sb, "HaveVolumeModifier", flag(hasVolumeModifier)); + line(sb, "VolumeModifier", formatVolume(volumeModifier)); + line(sb, "WhisperAllow", flag(whisperAllowed)); + line(sb, "PhoneticNickname", phoneticNickname); + line(sb, "LastSeen", lastSeen == null ? "" : LAST_SEEN.format(lastSeen)); + line(sb, "LastSeenServerName", lastSeenServerName); + line(sb, "LastSeenServerAddress", lastSeenServerAddress); + return sb.toString(); + } + + /** Parses a TeamSpeak contact record, or returns {@code null} when it carries no unique id. */ + public static Contact parseRecord(String record) { + if (record == null) return null; + Map fields = new LinkedHashMap<>(); + for (String line : record.split("\r?\n")) { + int eq = line.indexOf('='); + if (eq > 0) fields.put(line.substring(0, eq).trim(), line.substring(eq + 1)); + } + String uid = fields.getOrDefault("IDS", "").trim(); + if (uid.isEmpty()) return null; + + Contact c = new Contact(uid); + c.nickname = fields.getOrDefault("Nickname", ""); + c.type = ContactType.fromCode(parseInt(fields.get("Friend"), ContactType.NEUTRAL.code())); + c.automute = parseFlag(fields.get("Automute")); + c.ignorePublicMessages = parseFlag(fields.get("IgnorePublicMessages")); + c.ignorePrivateMessages = parseFlag(fields.get("IgnorePrivateMessages")); + c.ignorePokes = parseFlag(fields.get("IgnorePokes")); + c.ignoreAvatar = parseFlag(fields.get("IgnoreAvatar")); + c.ignoreAwayMessage = parseFlag(fields.get("IgnoreAwayMessage")); + c.nicknameDisplay = NicknameDisplay.fromCode(parseInt(fields.get("NickShowType"), 0)); + c.hasVolumeModifier = parseFlag(fields.get("HaveVolumeModifier")); + c.volumeModifier = parseDouble(fields.get("VolumeModifier"), 0); + c.whisperAllowed = !fields.containsKey("WhisperAllow") || parseFlag(fields.get("WhisperAllow")); + c.phoneticNickname = fields.getOrDefault("PhoneticNickname", ""); + c.lastSeen = parseLastSeen(fields.get("LastSeen")); + c.lastSeenServerName = fields.getOrDefault("LastSeenServerName", ""); + // TeamSpeak's own writer misspells the key in one of its code paths. + c.lastSeenServerAddress = fields.getOrDefault("LastSeenServerAddress", + fields.getOrDefault("LastSeenServerAddres", "")); + return c; + } + + private static void line(StringBuilder sb, String key, String value) { + sb.append(key).append('=').append(value == null ? "" : value.replace("\n", " ")).append('\n'); + } + + private static String flag(boolean b) { + return b ? "1" : "0"; + } + + private static String formatVolume(double db) { + if (db == Math.rint(db)) return Integer.toString((int) db); + return String.format(Locale.ROOT, "%.2f", db); + } + + private static boolean parseFlag(String v) { + if (v == null) return false; + String t = v.trim(); + return t.equals("1") || t.equalsIgnoreCase("true"); + } + + private static int parseInt(String v, int def) { + if (v == null) return def; + try { + return Integer.parseInt(v.trim()); + } catch (NumberFormatException e) { + return def; + } + } + + private static double parseDouble(String v, double def) { + if (v == null) return def; + try { + return Double.parseDouble(v.trim()); + } catch (NumberFormatException e) { + return def; + } + } + + private static LocalDateTime parseLastSeen(String v) { + if (v == null || v.isBlank()) return null; + try { + return LocalDateTime.parse(v.trim(), LAST_SEEN); + } catch (DateTimeParseException e) { + return null; + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/contacts/ContactDefaults.java b/ts3-client/core/src/main/java/com/ts3client/contacts/ContactDefaults.java new file mode 100644 index 0000000..fe9a71f --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/contacts/ContactDefaults.java @@ -0,0 +1,99 @@ +package com.ts3client.contacts; + +import java.util.Properties; + +/** + * What a newly added contact starts out with, kept separately for friends, blocked + * and neutral clients — TeamSpeak's "Contact Manager Default Settings". + */ +public final class ContactDefaults { + + /** The defaults for one contact type. */ + public static final class Entry { + public NicknameDisplay nicknameDisplay = NicknameDisplay.BOTH; + public boolean automute; + public boolean ignorePublicMessages; + public boolean ignorePrivateMessages; + public boolean ignorePokes; + public boolean ignoreAvatar; + public boolean ignoreAwayMessage; + public boolean ignoreWhisper; + + public void copyFrom(Entry other) { + nicknameDisplay = other.nicknameDisplay; + automute = other.automute; + ignorePublicMessages = other.ignorePublicMessages; + ignorePrivateMessages = other.ignorePrivateMessages; + ignorePokes = other.ignorePokes; + ignoreAvatar = other.ignoreAvatar; + ignoreAwayMessage = other.ignoreAwayMessage; + ignoreWhisper = other.ignoreWhisper; + } + + private void load(Properties p, String prefix) { + nicknameDisplay = parseDisplay(p.getProperty(prefix + "nicknameDisplay"), nicknameDisplay); + automute = parseB(p.getProperty(prefix + "automute"), automute); + ignorePublicMessages = parseB(p.getProperty(prefix + "ignorePublicMessages"), ignorePublicMessages); + ignorePrivateMessages = parseB(p.getProperty(prefix + "ignorePrivateMessages"), ignorePrivateMessages); + ignorePokes = parseB(p.getProperty(prefix + "ignorePokes"), ignorePokes); + ignoreAvatar = parseB(p.getProperty(prefix + "ignoreAvatar"), ignoreAvatar); + ignoreAwayMessage = parseB(p.getProperty(prefix + "ignoreAwayMessage"), ignoreAwayMessage); + ignoreWhisper = parseB(p.getProperty(prefix + "ignoreWhisper"), ignoreWhisper); + } + + private void store(Properties p, String prefix) { + p.setProperty(prefix + "nicknameDisplay", nicknameDisplay.name()); + p.setProperty(prefix + "automute", Boolean.toString(automute)); + p.setProperty(prefix + "ignorePublicMessages", Boolean.toString(ignorePublicMessages)); + p.setProperty(prefix + "ignorePrivateMessages", Boolean.toString(ignorePrivateMessages)); + p.setProperty(prefix + "ignorePokes", Boolean.toString(ignorePokes)); + p.setProperty(prefix + "ignoreAvatar", Boolean.toString(ignoreAvatar)); + p.setProperty(prefix + "ignoreAwayMessage", Boolean.toString(ignoreAwayMessage)); + p.setProperty(prefix + "ignoreWhisper", Boolean.toString(ignoreWhisper)); + } + } + + private static final String PREFIX = "contacts.defaults."; + + private final Entry friend = new Entry(); + private final Entry blocked = new Entry(); + private final Entry neutral = new Entry(); + + public Entry of(ContactType type) { + switch (type) { + case FRIEND: + return friend; + case BLOCKED: + return blocked; + default: + return neutral; + } + } + + public void copyFrom(ContactDefaults other) { + friend.copyFrom(other.friend); + blocked.copyFrom(other.blocked); + neutral.copyFrom(other.neutral); + } + + public void load(Properties p) { + for (ContactType t : ContactType.values()) of(t).load(p, PREFIX + t.name().toLowerCase() + "."); + } + + public void store(Properties p) { + for (ContactType t : ContactType.values()) of(t).store(p, PREFIX + t.name().toLowerCase() + "."); + } + + private static boolean parseB(String v, boolean def) { + return v == null ? def : Boolean.parseBoolean(v.trim()); + } + + private static NicknameDisplay parseDisplay(String v, NicknameDisplay def) { + if (v == null) return def; + try { + return NicknameDisplay.valueOf(v.trim()); + } catch (IllegalArgumentException e) { + return def; + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/contacts/ContactStore.java b/ts3-client/core/src/main/java/com/ts3client/contacts/ContactStore.java new file mode 100644 index 0000000..a782fc0 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/contacts/ContactStore.java @@ -0,0 +1,235 @@ +package com.ts3client.contacts; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * The contact list (TeamSpeak's "Friend/Foe manager"): who is a friend, who is + * blocked, and how each of them is displayed and filtered. + * + *

Persisted in {@code ~/.ts3jclient/contacts.txt} as TeamSpeak's own contact + * records separated by blank lines — the same text the official client keeps in + * the {@code Contacts} table of its {@code settings.db}, so entries can be moved + * between the two verbatim. On first run the list is seeded from an installed + * TeamSpeak client's database, when one is found. + * + *

Safe to use from any thread: lookups are lock-free on a snapshot, and + * mutations are serialised and announced to listeners on the calling thread. + */ +public final class ContactStore { + + /** Told after the list changed in any way; called on whatever thread made the change. */ + public interface Listener { + void onContactsChanged(); + } + + private static final File DIR = new File(System.getProperty("user.home"), ".ts3jclient"); + private static final File FILE = new File(DIR, "contacts.txt"); + + private final File file; + private final Map byUid = new LinkedHashMap<>(); + /** Immutable snapshot for readers, replaced under the lock after every change. */ + private volatile List snapshot = List.of(); + private final List listeners = new CopyOnWriteArrayList<>(); + + ContactStore(File file) { + this.file = file; + } + + /** Loads the list, seeding it from the TeamSpeak 3 client's database the first time round. */ + public static ContactStore load() { + ContactStore store = new ContactStore(FILE); + if (FILE.isFile()) { + store.readFile(); + } else { + List imported = TeamSpeakSettingsDb.readContacts(); + if (!imported.isEmpty()) { + store.replaceAll(imported); + store.save(); + } + } + return store; + } + + void readFile() { + String text; + try { + text = Files.readString(file.toPath(), StandardCharsets.UTF_8); + } catch (IOException e) { + return; + } + List parsed = new ArrayList<>(); + for (String record : text.split("\\r?\\n\\s*\\r?\\n")) { + Contact c = Contact.parseRecord(record); + if (c != null) parsed.add(c); + } + synchronized (this) { + byUid.clear(); + for (Contact c : parsed) byUid.put(c.uniqueId, c); + snapshot = List.copyOf(byUid.values()); + } + } + + public void save() { + List all = snapshot; + StringBuilder sb = new StringBuilder(); + for (Contact c : all) { + if (sb.length() > 0) sb.append('\n'); + sb.append(c.toRecord()); + } + try { + if (!DIR.isDirectory()) { + //noinspection ResultOfMethodCallIgnored + DIR.mkdirs(); + } + Files.writeString(file.toPath(), sb.toString(), StandardCharsets.UTF_8); + } catch (IOException ignored) { + } + } + + public void addListener(Listener l) { + listeners.add(l); + } + + public void removeListener(Listener l) { + listeners.remove(l); + } + + // ---- lookups ---- + + /** Every contact, in list order. */ + public List all() { + return snapshot; + } + + public Contact find(String uniqueId) { + if (uniqueId == null || uniqueId.isEmpty()) return null; + for (Contact c : snapshot) { + if (c.uniqueId.equals(uniqueId)) return c; + } + return null; + } + + /** The type a client is filed under, {@code null} when it is not a contact. */ + public ContactType typeOf(String uniqueId) { + Contact c = find(uniqueId); + return c == null ? null : c.type; + } + + /** The name a client should be shown under: its contact's custom name, else the nickname. */ + public String displayName(String uniqueId, String nickname) { + Contact c = find(uniqueId); + return c == null ? nickname : c.displayName(nickname); + } + + // ---- mutations ---- + + /** + * Files a client as a contact of the given type, applying the type's defaults to + * a new entry and only re-typing an existing one. + * + * @return the resulting contact + */ + public Contact add(String uniqueId, String nickname, ContactType type, ContactDefaults defaults) { + Contact contact; + synchronized (this) { + contact = byUid.get(uniqueId); + if (contact == null) { + contact = new Contact(uniqueId); + contact.nickname = nickname == null ? "" : nickname; + contact.applyDefaults(defaults.of(type)); + byUid.put(uniqueId, contact); + } + contact.type = type; + snapshot = List.copyOf(byUid.values()); + } + changed(); + return contact; + } + + /** Stores an edited contact, replacing any entry with the same unique id. */ + public void put(Contact contact) { + synchronized (this) { + Contact existing = byUid.get(contact.uniqueId); + if (existing != null && existing != contact) existing.copyFrom(contact); + else byUid.put(contact.uniqueId, contact); + snapshot = List.copyOf(byUid.values()); + } + changed(); + } + + public void remove(String uniqueId) { + synchronized (this) { + if (byUid.remove(uniqueId) == null) return; + snapshot = List.copyOf(byUid.values()); + } + changed(); + } + + /** Takes over a whole list, e.g. one read from the TeamSpeak client. */ + public void replaceAll(Collection contacts) { + synchronized (this) { + byUid.clear(); + for (Contact c : contacts) byUid.put(c.uniqueId, c); + snapshot = List.copyOf(byUid.values()); + } + changed(); + } + + /** + * Merges contacts read from elsewhere: entries for clients not on the list yet are + * added, existing ones are overwritten. + * + * @return how many entries were new + */ + public int merge(Collection contacts) { + int added = 0; + synchronized (this) { + for (Contact c : contacts) { + if (byUid.put(c.uniqueId, c) == null) added++; + } + snapshot = List.copyOf(byUid.values()); + } + changed(); + return added; + } + + /** + * Notes that a contact is online right now. A sighting within the same minute on the + * same server changes nothing, so a busy server does not rewrite the file per event. + */ + public void markSeen(String uniqueId, String serverName, String serverAddress) { + Contact c = find(uniqueId); + if (c == null) return; + LocalDateTime now = LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS); + synchronized (this) { + if (c.lastSeen != null && ChronoUnit.MINUTES.between(c.lastSeen, now) < 1 + && c.lastSeenServerAddress.equals(orEmpty(serverAddress))) { + return; + } + c.lastSeen = now; + c.lastSeenServerName = orEmpty(serverName); + c.lastSeenServerAddress = orEmpty(serverAddress); + } + changed(); + } + + private void changed() { + save(); + for (Listener l : listeners) l.onContactsChanged(); + } + + private static String orEmpty(String s) { + return s == null ? "" : s; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/contacts/ContactType.java b/ts3-client/core/src/main/java/com/ts3client/contacts/ContactType.java new file mode 100644 index 0000000..6a7571d --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/contacts/ContactType.java @@ -0,0 +1,29 @@ +package com.ts3client.contacts; + +/** How a contact is classed; the codes are the ones TeamSpeak stores in {@code Friend=}. */ +public enum ContactType { + FRIEND(0, "Friend"), + BLOCKED(1, "Blocked"), + NEUTRAL(2, "Neutral"); + + private final int code; + private final String label; + + ContactType(int code, String label) { + this.code = code; + this.label = label; + } + + public int code() { + return code; + } + + public String label() { + return label; + } + + public static ContactType fromCode(int code) { + for (ContactType t : values()) if (t.code == code) return t; + return NEUTRAL; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/contacts/NicknameDisplay.java b/ts3-client/core/src/main/java/com/ts3client/contacts/NicknameDisplay.java new file mode 100644 index 0000000..6b4df70 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/contacts/NicknameDisplay.java @@ -0,0 +1,43 @@ +package com.ts3client.contacts; + +/** Which name a contact is shown under; the codes are TeamSpeak's {@code NickShowType=}. */ +public enum NicknameDisplay { + /** "Custom name and nickname": the custom name with the live nickname in brackets. */ + BOTH(0, "Custom name and nickname"), + ONLY_CUSTOM(1, "Only custom name"), + ONLY_NICKNAME(2, "Only nickname"); + + private final int code; + private final String label; + + NicknameDisplay(int code, String label) { + this.code = code; + this.label = label; + } + + public int code() { + return code; + } + + public String label() { + return label; + } + + public static NicknameDisplay fromCode(int code) { + for (NicknameDisplay d : values()) if (d.code == code) return d; + return BOTH; + } + + /** Renders a nickname the way this mode asks for it; a blank custom name always yields the nickname. */ + public String render(String customName, String nickname) { + if (customName == null || customName.isBlank()) return nickname; + switch (this) { + case ONLY_CUSTOM: + return customName; + case ONLY_NICKNAME: + return nickname; + default: + return customName.equals(nickname) ? nickname : customName + " (" + nickname + ")"; + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/contacts/SqliteReader.java b/ts3-client/core/src/main/java/com/ts3client/contacts/SqliteReader.java new file mode 100644 index 0000000..ec52622 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/contacts/SqliteReader.java @@ -0,0 +1,288 @@ +package com.ts3client.contacts; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * A minimal read-only reader for SQLite 3 database files, enough to dump the rows + * of a table: it walks the table's b-tree and decodes each record, overflow pages + * included. It exists so the TeamSpeak client's {@code settings.db} can be read + * without a JDBC driver and its native library. + * + *

Only the main database file is read. A write-ahead log that has not been + * checkpointed yet is not consulted; TeamSpeak uses a rollback journal, whose + * committed state is in the main file. + */ +final class SqliteReader implements AutoCloseable { + + private static final int HEADER_SIZE = 100; + private static final int INTERIOR_TABLE = 0x05; + private static final int LEAF_TABLE = 0x0d; + + /** A table's column names and rows; values are {@link Long}, {@link Double}, {@link String}, {@code byte[]} or {@code null}. */ + static final class Table { + final List columns; + final List rows; + + Table(List columns, List rows) { + this.columns = columns; + this.rows = rows; + } + + int columnIndex(String name) { + for (int i = 0; i < columns.size(); i++) { + if (columns.get(i).equalsIgnoreCase(name)) return i; + } + return -1; + } + } + + private final RandomAccessFile file; + private final int pageSize; + private final int usableSize; + private final int encoding; + + SqliteReader(java.io.File path) throws IOException { + file = new RandomAccessFile(path, "r"); + byte[] header = new byte[HEADER_SIZE]; + file.readFully(header); + if (!new String(header, 0, 15, StandardCharsets.US_ASCII).equals("SQLite format 3")) { + file.close(); + throw new IOException("Not an SQLite 3 database"); + } + int size = u16(header, 16); + pageSize = size == 1 ? 65536 : size; + usableSize = pageSize - (header[20] & 0xFF); + encoding = (int) u32(header, 56); + } + + @Override + public void close() throws IOException { + file.close(); + } + + /** Reads a whole table, or returns {@code null} when the schema has no such table. */ + Table readTable(String tableName) throws IOException { + Object[] schema = null; + for (Object[] row : rowsOf(1)) { + if (row.length < 5 || !"table".equals(row[0])) continue; + if (String.valueOf(row[1]).equalsIgnoreCase(tableName)) { + schema = row; + break; + } + } + if (schema == null || !(schema[3] instanceof Long)) return null; + List columns = columnNames(String.valueOf(schema[4])); + return new Table(columns, rowsOf(((Long) schema[3]).intValue())); + } + + /** The column names out of a {@code CREATE TABLE} statement, in declaration order. */ + static List columnNames(String sql) { + List names = new ArrayList<>(); + int open = sql.indexOf('('); + int close = sql.lastIndexOf(')'); + if (open < 0 || close <= open) return names; + for (String definition : splitTopLevel(sql.substring(open + 1, close))) { + String[] tokens = definition.trim().split("\\s+"); + if (tokens.length == 0 || tokens[0].isEmpty()) continue; + String first = tokens[0].toUpperCase(Locale.ROOT); + // Table constraints live in the same list as columns. + if (first.equals("PRIMARY") || first.equals("UNIQUE") || first.equals("CHECK") + || first.equals("FOREIGN") || first.equals("CONSTRAINT")) { + continue; + } + names.add(unquote(tokens[0])); + } + return names; + } + + /** Splits on commas outside parentheses, so {@code PRIMARY KEY (a, b)} stays one item. */ + private static List splitTopLevel(String s) { + List parts = new ArrayList<>(); + int depth = 0; + int start = 0; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '(') depth++; + else if (c == ')') depth--; + else if (c == ',' && depth == 0) { + parts.add(s.substring(start, i)); + start = i + 1; + } + } + parts.add(s.substring(start)); + return parts; + } + + private static String unquote(String s) { + if (s.length() >= 2) { + char c = s.charAt(0); + if ((c == '"' || c == '`' || c == '\'' || c == '[') && (s.endsWith(String.valueOf(c)) || c == '[' && s.endsWith("]"))) { + return s.substring(1, s.length() - 1); + } + } + return s; + } + + // ---- b-tree walk ---- + + private List rowsOf(int rootPage) throws IOException { + List rows = new ArrayList<>(); + walk(rootPage, rows, 0); + return rows; + } + + private void walk(int pageNumber, List rows, int depth) throws IOException { + if (depth > 64) throw new IOException("B-tree too deep"); + byte[] page = readPage(pageNumber); + int base = pageNumber == 1 ? HEADER_SIZE : 0; + int type = page[base] & 0xFF; + int cells = u16(page, base + 3); + int headerSize = type == INTERIOR_TABLE ? 12 : 8; + int pointers = base + headerSize; + + if (type == INTERIOR_TABLE) { + for (int i = 0; i < cells; i++) { + int cell = u16(page, pointers + 2 * i); + walk((int) u32(page, cell), rows, depth + 1); + } + walk((int) u32(page, base + 8), rows, depth + 1); + } else if (type == LEAF_TABLE) { + for (int i = 0; i < cells; i++) { + int cell = u16(page, pointers + 2 * i); + rows.add(decodeRecord(leafPayload(page, cell))); + } + } else { + throw new IOException("Unexpected page type " + type); + } + } + + /** The full payload of a table leaf cell, following the overflow chain when it spills. */ + private byte[] leafPayload(byte[] page, int cell) throws IOException { + long[] varint = new long[2]; + int pos = readVarint(page, cell, varint); + int payloadSize = (int) varint[0]; + pos = readVarint(page, pos, varint); // rowid + + int usable = usableSize; + int maxLocal = usable - 35; + int minLocal = (usable - 12) * 32 / 255 - 23; + int local; + if (payloadSize <= maxLocal) { + local = payloadSize; + } else { + int k = minLocal + (payloadSize - minLocal) % (usable - 4); + local = k <= maxLocal ? k : minLocal; + } + + byte[] payload = new byte[payloadSize]; + System.arraycopy(page, pos, payload, 0, local); + int filled = local; + long next = local < payloadSize ? u32(page, pos + local) : 0; + while (filled < payloadSize && next != 0) { + byte[] overflow = readPage((int) next); + int chunk = Math.min(payloadSize - filled, usable - 4); + System.arraycopy(overflow, 4, payload, filled, chunk); + filled += chunk; + next = u32(overflow, 0); + } + if (filled < payloadSize) throw new IOException("Truncated overflow chain"); + return payload; + } + + private Object[] decodeRecord(byte[] payload) throws IOException { + long[] varint = new long[2]; + int pos = readVarint(payload, 0, varint); + int headerEnd = (int) varint[0]; + List types = new ArrayList<>(); + while (pos < headerEnd) { + pos = readVarint(payload, pos, varint); + types.add(varint[0]); + } + Object[] values = new Object[types.size()]; + int data = headerEnd; + for (int i = 0; i < values.length; i++) { + long type = types.get(i); + if (type == 0) { + values[i] = null; + } else if (type >= 1 && type <= 6) { + int width = type == 5 ? 6 : type == 6 ? 8 : (int) type; + values[i] = readInt(payload, data, width); + data += width; + } else if (type == 7) { + values[i] = Double.longBitsToDouble(readInt(payload, data, 8)); + data += 8; + } else if (type == 8 || type == 9) { + values[i] = type - 8; + } else if (type >= 12) { + int length = (int) ((type - (type % 2 == 0 ? 12 : 13)) / 2); + if (type % 2 == 0) { + byte[] blob = new byte[length]; + System.arraycopy(payload, data, blob, 0, length); + values[i] = blob; + } else { + values[i] = decodeText(payload, data, length); + } + data += length; + } else { + throw new IOException("Reserved serial type " + type); + } + } + return values; + } + + private String decodeText(byte[] b, int off, int len) { + switch (encoding) { + case 2: + return new String(b, off, len, StandardCharsets.UTF_16LE); + case 3: + return new String(b, off, len, StandardCharsets.UTF_16BE); + default: + return new String(b, off, len, StandardCharsets.UTF_8); + } + } + + private byte[] readPage(int number) throws IOException { + if (number < 1) throw new IOException("Bad page number " + number); + byte[] page = new byte[pageSize]; + file.seek((long) (number - 1) * pageSize); + file.readFully(page); + return page; + } + + // ---- encoding primitives ---- + + private static int u16(byte[] b, int off) { + return (b[off] & 0xFF) << 8 | (b[off + 1] & 0xFF); + } + + private static long u32(byte[] b, int off) { + return (long) (b[off] & 0xFF) << 24 | (b[off + 1] & 0xFF) << 16 | (b[off + 2] & 0xFF) << 8 | (b[off + 3] & 0xFF); + } + + private static long readInt(byte[] b, int off, int width) { + long v = b[off] & 0xFF; + if ((v & 0x80) != 0) v |= -256L; // sign-extend + for (int i = 1; i < width; i++) v = v << 8 | (b[off + i] & 0xFF); + return v; + } + + /** Reads a big-endian varint into {@code out[0]} and returns the position after it. */ + private static int readVarint(byte[] b, int off, long[] out) { + long v = 0; + for (int i = 0; i < 8; i++) { + int c = b[off + i] & 0xFF; + v = v << 7 | (c & 0x7F); + if ((c & 0x80) == 0) { + out[0] = v; + return off + i + 1; + } + } + out[0] = v << 8 | (b[off + 8] & 0xFF); + return off + 9; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/contacts/TeamSpeakSettingsDb.java b/ts3-client/core/src/main/java/com/ts3client/contacts/TeamSpeakSettingsDb.java new file mode 100644 index 0000000..563b5f4 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/contacts/TeamSpeakSettingsDb.java @@ -0,0 +1,66 @@ +package com.ts3client.contacts; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * Reads contacts straight out of an installed TeamSpeak 3 client's + * {@code settings.db}, whose {@code Contacts} table holds one record per contact + * in the format {@link Contact#parseRecord} understands. + */ +public final class TeamSpeakSettingsDb { + + /** Overrides where the TeamSpeak client keeps its configuration. */ + private static final String CONFIG_DIR_PROPERTY = "ts3.config.dir"; + + private TeamSpeakSettingsDb() { + } + + /** The TeamSpeak client's settings database on this machine, or {@code null} when there is none. */ + public static File locate() { + String configured = System.getProperty(CONFIG_DIR_PROPERTY); + List candidates = new ArrayList<>(); + if (configured != null && !configured.isBlank()) candidates.add(new File(configured.trim())); + + String home = System.getProperty("user.home", "."); + candidates.add(new File(home, ".ts3client")); + String appData = System.getenv("APPDATA"); + if (appData != null) candidates.add(new File(appData, "TS3Client")); + candidates.add(new File(home, "Library/Application Support/TeamSpeak 3")); + + for (File dir : candidates) { + File db = new File(dir, "settings.db"); + if (db.isFile()) return db; + } + return null; + } + + /** The contacts of the local TeamSpeak client, or an empty list when it has none or cannot be read. */ + public static List readContacts() { + File db = locate(); + if (db == null) return List.of(); + try { + return readContacts(db); + } catch (Exception e) { + return List.of(); + } + } + + /** @throws java.io.IOException when the file is not a readable SQLite database */ + public static List readContacts(File db) throws java.io.IOException { + List contacts = new ArrayList<>(); + try (SqliteReader reader = new SqliteReader(db)) { + SqliteReader.Table table = reader.readTable("Contacts"); + if (table == null) return contacts; + int value = table.columnIndex("value"); + if (value < 0) return contacts; + for (Object[] row : table.rows) { + if (value >= row.length || !(row[value] instanceof String)) continue; + Contact c = Contact.parseRecord((String) row[value]); + if (c != null) contacts.add(c); + } + } + return contacts; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java index 2d09367..a81b66e 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java @@ -1,6 +1,8 @@ package com.ts3client.net; import com.github.manevolent.ts3j.event.*; +import com.github.manevolent.ts3j.api.TextMessageTargetMode; +import com.ts3client.contacts.Contact; import com.ts3client.sound.SoundEvent; import com.ts3client.sound.SoundNotifier; @@ -48,6 +50,7 @@ final class ConnectionEventHandler implements TS3Listener { c.channelGroupId = e.getClientChannelGroupId(); c.self = (e.getClientId() == conn.getSelfClientId()); if (e.getClientId() != conn.getSelfClientId()) { + conn.applyContact(c); announceClientEntered(e); logClientEntered(e); } @@ -570,6 +573,11 @@ final class ConnectionEventHandler implements TS3Listener { @Override public void onTextMessage(TextMessageEvent e) { if (e.getInvokerId() == conn.getSelfClientId()) return; // don't echo our own + Contact contact = conn.contacts.find(e.getInvokerUniqueId()); + if (contact != null) { + boolean isPrivate = e.getTargetMode() == TextMessageTargetMode.CLIENT; + if (isPrivate ? contact.ignorePrivateMessages : contact.ignorePublicMessages) return; + } ConnectionListener.ChatScope scope; SoundEvent notification; switch (e.getTargetMode()) { @@ -593,8 +601,11 @@ final class ConnectionEventHandler implements TS3Listener { @Override public void onClientPoke(ClientPokeEvent e) { + Contact contact = conn.contacts.find(e.getInvokerUniqueId()); + if (contact != null && contact.ignorePokes) return; conn.sound(SoundEvent.OTHER_RECEIVED_POKE, conn.clientVars(e.getInvokerId(), e.getInvokerName())); - conn.ui.onPoke(TeamspeakConnection.orEmpty(e.getInvokerName()), TeamspeakConnection.orEmpty(e.get("msg"))); + String from = conn.contacts.displayName(e.getInvokerUniqueId(), TeamspeakConnection.orEmpty(e.getInvokerName())); + conn.ui.onPoke(from, TeamspeakConnection.orEmpty(e.get("msg"))); } @Override diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java index 84377cb..7fbb8bb 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java @@ -306,6 +306,11 @@ public final class ServerModel { clients.remove(id); } + /** A snapshot of every client on the server. */ + public synchronized List allClients() { + return new ArrayList<>(clients.values()); + } + public synchronized ClientEntry findClientByUniqueId(String uniqueId) { if (uniqueId == null || uniqueId.isEmpty()) return null; for (ClientEntry c : clients.values()) { diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index 7f2874d..0320983 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -17,6 +17,8 @@ import com.ts3client.audio.VoiceOutput; import com.ts3client.chatlog.ChatLogScope; import com.ts3client.chatlog.ChatLogStore; import com.ts3client.config.Settings; +import com.ts3client.contacts.Contact; +import com.ts3client.contacts.ContactStore; import com.ts3client.net.filetransfer.FileTransfer; import com.ts3client.net.filetransfer.FileTransferManager; import com.ts3client.net.ban.BanEntry; @@ -66,6 +68,8 @@ public final class TeamspeakConnection implements TS3Listener { private final IconRepository icons; /** Package-private: read directly by {@link ConnectionEventHandler}. */ final SoundNotifier sounds; + /** Friends, blocked clients and their per-client filters; also read by {@link ConnectionEventHandler}. */ + final ContactStore contacts; /** The {@link TS3Listener} side of this connection; see its class doc. */ private final ConnectionEventHandler events = new ConnectionEventHandler(this); /** Builds {@code getconnectioninfo} snapshots; also package-private for {@link ConnectionEventHandler}. */ @@ -112,11 +116,12 @@ public final class TeamspeakConnection implements TS3Listener { private volatile long lastMutedTalkNanos; public TeamspeakConnection(Settings settings, AudioBackend audio, ConnectionListener ui, - SoundNotifier sounds) { + SoundNotifier sounds, ContactStore contacts) { this.settings = settings; this.audio = audio; this.ui = ui; this.sounds = sounds; + this.contacts = contacts; this.icons = new IconRepository(this::fetchIcon, ui::onIconsUpdated); } @@ -266,7 +271,9 @@ public final class TeamspeakConnection implements TS3Listener { client.addListener(this); client.addListener(events); client.setVoiceHandler(playback::handleVoice); - client.setWhisperHandler(playback::handleWhisper); + client.setWhisperHandler(whisper -> { + if (isWhisperAllowed(whisper.getClientId())) playback.handleWhisper(whisper); + }); client.setExceptionHandler(t -> { // A late packet dispatched after close() rejects on the shut-down // command executor; harmless during teardown. @@ -998,6 +1005,76 @@ public final class TeamspeakConnection implements TS3Listener { * Our own unique identifier on this server, which is how the server names us as a * ban's creator; empty while not connected. */ + // ---- contacts ---- + + public ContactStore getContacts() { + return contacts; + } + + /** The name a client is shown under: its contact's custom name where it has one. */ + public String displayName(ClientEntry client) { + return contacts.displayName(client.uniqueId, client.nickname); + } + + /** + * Whether a whisper from a client is played, per the global whisper policy and the + * client's contact entry. A client that has already left is judged by the policy alone. + */ + boolean isWhisperAllowed(int clientId) { + Settings.WhisperPolicy policy = settings.whisperPolicy; + if (policy == Settings.WhisperPolicy.NOBODY) return false; + ClientEntry c = model.getClient(clientId); + Contact contact = c == null ? null : contacts.find(c.uniqueId); + if (contact != null) return contact.whisperAllowed; + return policy == Settings.WhisperPolicy.ALLOW_UNLESS_DENIED; + } + + /** + * Applies a client's contact entry to its playback — the automatic mute and the + * volume modifier — and records the sighting. Called when the client appears and + * whenever its entry is edited. + */ + void applyContact(ClientEntry client) { + Contact contact = applyContactPlayback(client); + if (contact != null) contacts.markSeen(client.uniqueId, model.getServerName(), serverHost + ":" + serverPort); + } + + private Contact applyContactPlayback(ClientEntry client) { + if (client == null || client.self) return null; + Contact contact = contacts.find(client.uniqueId); + if (contact != null && playback != null) { + if (contact.automute) playback.setClientMuted(client.id, true); + playback.setClientVolume(client.id, contact.volumeGain()); + } + return contact; + } + + /** + * Re-applies every contact entry to the clients on the server, after the list changed. + * A client that just became a contact is also marked as seen; the store only reports + * that as a change once, so the re-application it triggers stops there. + */ + public void applyContacts() { + if (!connected) return; + for (ClientEntry c : model.allClients()) applyContact(c); + } + + /** Remembers a per-client volume, in the contact entry when the client has one. */ + public void setClientVolumeModifier(ClientEntry client, double decibels) { + if (playback != null) playback.setClientVolume(client.id, Math.pow(10.0, decibels / 20.0)); + Contact contact = contacts.find(client.uniqueId); + if (contact == null) return; + contact.hasVolumeModifier = decibels != 0; + contact.volumeModifier = decibels; + contacts.put(contact); + } + + public double getClientVolumeModifier(ClientEntry client) { + if (playback == null) return 0; + double gain = playback.getClientVolume(client.id); + return gain <= 0 ? -60 : 20.0 * Math.log10(gain); + } + public String getSelfUniqueId() { ClientEntry self = model.getClient(selfClientId); if (self != null && !self.uniqueId.isEmpty()) return self.uniqueId; diff --git a/ts3-client/core/src/test/java/com/ts3client/contacts/ContactStoreTest.java b/ts3-client/core/src/test/java/com/ts3client/contacts/ContactStoreTest.java new file mode 100644 index 0000000..2f48a2d --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/contacts/ContactStoreTest.java @@ -0,0 +1,99 @@ +package com.ts3client.contacts; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ContactStoreTest { + + @TempDir + File dir; + + @Test + void addsWithTheTypeDefaultsAndPersistsInTeamSpeakRecords() throws Exception { + File file = new File(dir, "contacts.txt"); + ContactStore store = new ContactStore(file); + ContactDefaults defaults = new ContactDefaults(); + defaults.of(ContactType.BLOCKED).ignorePokes = true; + defaults.of(ContactType.BLOCKED).ignoreWhisper = true; + defaults.of(ContactType.BLOCKED).nicknameDisplay = NicknameDisplay.ONLY_NICKNAME; + + AtomicInteger changes = new AtomicInteger(); + store.addListener(changes::incrementAndGet); + Contact bob = store.add("bob=", "Bob", ContactType.BLOCKED, defaults); + assertTrue(bob.ignorePokes); + assertTrue(!bob.whisperAllowed); + assertEquals(NicknameDisplay.ONLY_NICKNAME, bob.nicknameDisplay); + assertEquals("Bob2", store.displayName("bob=", "Bob2")); + bob.nicknameDisplay = NicknameDisplay.BOTH; + assertEquals("Bob (Bob2)", store.displayName("bob=", "Bob2")); + assertEquals(1, changes.get()); + + // Adding again only re-types; the edited flags stay. + bob.ignorePrivateMessages = true; + store.put(bob); + Contact again = store.add("bob=", "Other", ContactType.FRIEND, defaults); + assertTrue(again.ignorePrivateMessages); + assertEquals(ContactType.FRIEND, again.type); + assertEquals(ContactType.FRIEND, store.typeOf("bob=")); + assertNull(store.typeOf("nobody")); + + String text = Files.readString(file.toPath(), StandardCharsets.UTF_8); + assertTrue(text.startsWith("IDS=bob=\nNickname=Bob\nFriend=0\n"), text); + + ContactStore reloaded = new ContactStore(file); + reloaded.readFile(); + Contact back = reloaded.find("bob="); + assertNotNull(back); + assertTrue(back.ignorePrivateMessages); + assertEquals(ContactType.FRIEND, back.type); + } + + @Test + void markSeenRecordsOnceAMinute() { + ContactStore store = new ContactStore(new File(dir, "c.txt")); + store.add("a=", "A", ContactType.NEUTRAL, new ContactDefaults()); + AtomicInteger changes = new AtomicInteger(); + store.addListener(changes::incrementAndGet); + + store.markSeen("a=", "Server", "host:9987"); + store.markSeen("a=", "Server", "host:9987"); + store.markSeen("missing", "Server", "host:9987"); + assertEquals(1, changes.get()); + Contact a = store.find("a="); + assertEquals("Server", a.lastSeenServerName); + assertTrue(a.lastSeen.isAfter(LocalDateTime.now().minusMinutes(1))); + + store.markSeen("a=", "Other", "other:9987"); + assertEquals(2, changes.get()); + assertEquals("other:9987", a.lastSeenServerAddress); + } + + @Test + void mergeAddsNewAndOverwritesExisting() { + ContactStore store = new ContactStore(new File(dir, "m.txt")); + store.add("a=", "A", ContactType.NEUTRAL, new ContactDefaults()); + Contact importedA = new Contact("a="); + importedA.nickname = "A imported"; + importedA.type = ContactType.FRIEND; + Contact importedB = new Contact("b="); + importedB.nickname = "B"; + assertEquals(1, store.merge(List.of(importedA, importedB))); + assertEquals(2, store.all().size()); + assertEquals("A imported", store.find("a=").nickname); + assertEquals(ContactType.FRIEND, store.find("a=").type); + store.remove("a="); + assertEquals(1, store.all().size()); + } +} diff --git a/ts3-client/core/src/test/java/com/ts3client/contacts/ContactTest.java b/ts3-client/core/src/test/java/com/ts3client/contacts/ContactTest.java new file mode 100644 index 0000000..2f3dc35 --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/contacts/ContactTest.java @@ -0,0 +1,83 @@ +package com.ts3client.contacts; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ContactTest { + + @Test + void recordRoundTripsInTeamSpeakFormat() { + Contact c = new Contact("AbCdEfGh1234567890abcdefghijk="); + c.nickname = "Alice"; + c.type = ContactType.FRIEND; + c.automute = true; + c.ignorePokes = true; + c.nicknameDisplay = NicknameDisplay.ONLY_CUSTOM; + c.hasVolumeModifier = true; + c.volumeModifier = -6; + c.whisperAllowed = false; + c.phoneticNickname = "Ah-lees"; + c.lastSeen = LocalDateTime.of(2024, 3, 1, 18, 33, 34); + c.lastSeenServerName = "Test Server"; + c.lastSeenServerAddress = "server.lixko.eu:9987"; + + String record = c.toRecord(); + assertTrue(record.startsWith("IDS=AbCdEfGh1234567890abcdefghijk=\nNickname=Alice\nFriend=0\nAutomute=1\n")); + assertTrue(record.contains("\nVolumeModifier=-6\n")); + assertTrue(record.contains("\nWhisperAllow=0\n")); + assertTrue(record.contains("\nLastSeen=2024-03-01T18:33:34\n")); + + Contact parsed = Contact.parseRecord(record); + assertEquals(c.uniqueId, parsed.uniqueId); + assertEquals("Alice", parsed.nickname); + assertEquals(ContactType.FRIEND, parsed.type); + assertTrue(parsed.automute); + assertTrue(parsed.ignorePokes); + assertFalse(parsed.ignorePublicMessages); + assertEquals(NicknameDisplay.ONLY_CUSTOM, parsed.nicknameDisplay); + assertTrue(parsed.hasVolumeModifier); + assertEquals(-6.0, parsed.volumeModifier); + assertFalse(parsed.whisperAllowed); + assertEquals("Ah-lees", parsed.phoneticNickname); + assertEquals(c.lastSeen, parsed.lastSeen); + assertEquals("server.lixko.eu:9987", parsed.lastSeenServerAddress); + assertEquals(record, parsed.toRecord()); + } + + @Test + void missingFieldsFallBackToTeamSpeakDefaults() { + Contact c = Contact.parseRecord("IDS=abc=\nNickname=X\nFriend=1\n"); + assertEquals(ContactType.BLOCKED, c.type); + assertTrue(c.whisperAllowed); + assertEquals(NicknameDisplay.BOTH, c.nicknameDisplay); + assertNull(c.lastSeen); + assertNull(Contact.parseRecord("Nickname=nobody\n")); + } + + @Test + void displayNameFollowsTheShowType() { + Contact c = new Contact("u"); + c.nickname = "Custom"; + assertEquals("Custom (Live)", c.displayName("Live")); + assertEquals("Live", c.displayName("Live") == null ? null : NicknameDisplay.ONLY_NICKNAME.render("Custom", "Live")); + c.nicknameDisplay = NicknameDisplay.ONLY_CUSTOM; + assertEquals("Custom", c.displayName("Live")); + c.nickname = ""; + assertEquals("Live", c.displayName("Live")); + } + + @Test + void volumeModifierIsDecibels() { + Contact c = new Contact("u"); + assertEquals(1.0, c.volumeGain()); + c.hasVolumeModifier = true; + c.volumeModifier = -6; + assertEquals(0.501, c.volumeGain(), 0.001); + } +} diff --git a/ts3-client/core/src/test/java/com/ts3client/contacts/TeamSpeakSettingsDbTest.java b/ts3-client/core/src/test/java/com/ts3client/contacts/TeamSpeakSettingsDbTest.java new file mode 100644 index 0000000..871b28e --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/contacts/TeamSpeakSettingsDbTest.java @@ -0,0 +1,61 @@ +package com.ts3client.contacts; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TeamSpeakSettingsDbTest { + + private static File fixture() { + return new File(TeamSpeakSettingsDbTest.class.getResource("/contacts/settings.db").getFile()); + } + + @Test + void readsEveryContactOutOfTheDatabase() throws Exception { + List contacts = TeamSpeakSettingsDb.readContacts(fixture()); + assertEquals(60, contacts.size()); + + Contact alice = byUid(contacts, "AbCdEfGh1234567890abcdefghijk="); + assertEquals("Alice", alice.nickname); + assertEquals(ContactType.FRIEND, alice.type); + assertTrue(alice.hasVolumeModifier); + assertEquals(-6.0, alice.volumeModifier); + assertEquals("Test Server", alice.lastSeenServerName); + assertEquals(2024, alice.lastSeen.getYear()); + + Contact bob = byUid(contacts, "Zyxwvutsrqponmlkjihgfedcba00="); + assertEquals(ContactType.BLOCKED, bob.type); + assertTrue(bob.automute); + assertTrue(bob.ignorePokes); + assertTrue(!bob.whisperAllowed); + assertEquals(NicknameDisplay.ONLY_CUSTOM, bob.nicknameDisplay); + } + + /** The third record spills onto overflow pages at the fixture's 512-byte page size. */ + @Test + void followsOverflowPagesAndKeepsUnicode() throws Exception { + Contact carol = byUid(TeamSpeakSettingsDb.readContacts(fixture()), "Neutral0000000000000000000000="); + assertEquals("Carol Ünïcødé", carol.nickname); + assertEquals("Karol", carol.phoneticNickname); + assertEquals("X".repeat(900), carol.lastSeenServerName); + } + + @Test + void parsesColumnNamesOutOfCreateTable() { + assertEquals(List.of("timestamp", "key", "value"), SqliteReader.columnNames( + "CREATE TABLE Contacts (timestamp INTEGER UNSIGNED NOT NULL, key VARCHAR NOT NULL UNIQUE, value VARCHAR)")); + assertEquals(List.of("a", "b"), SqliteReader.columnNames( + "CREATE TABLE \"t\"(\"a\" TEXT, [b] INT, PRIMARY KEY (a, b))")); + } + + private static Contact byUid(List contacts, String uid) { + for (Contact c : contacts) if (c.uniqueId.equals(uid)) return c; + assertNotNull(null, "no contact " + uid); + return null; + } +} diff --git a/ts3-client/core/src/test/resources/contacts/settings.db b/ts3-client/core/src/test/resources/contacts/settings.db new file mode 100644 index 0000000..14183ed Binary files /dev/null and b/ts3-client/core/src/test/resources/contacts/settings.db differ diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceOutput.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceOutput.java index b57a573..560df61 100644 --- a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceOutput.java +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceOutput.java @@ -110,6 +110,8 @@ public final class DesktopVoiceOutput implements VoiceOutput { private final Map streams = new ConcurrentHashMap<>(); private final Set mutedClients = ConcurrentHashMap.newKeySet(); + /** Per-client gain on top of the master volume; absent means 1.0. */ + private final Map clientVolumes = new ConcurrentHashMap<>(); private volatile String outputDevice; private volatile double masterVolume = 1.0; @@ -175,6 +177,15 @@ public final class DesktopVoiceOutput implements VoiceOutput { return mutedClients.contains(clientId); } + public void setClientVolume(int clientId, double gain) { + if (gain == 1.0) clientVolumes.remove(clientId); + else clientVolumes.put(clientId, Math.max(0, gain)); + } + + public double getClientVolume(int clientId) { + return clientVolumes.getOrDefault(clientId, 1.0); + } + public void setOutputDevice(String device) { this.outputDevice = device; } @@ -333,7 +344,7 @@ public final class DesktopVoiceOutput implements VoiceOutput { try { float[] pcm = stream.pcm; int frames = stream.decoderFor(channels).decode(data, pcm); - double vol = masterVolume; + double vol = masterVolume * clientVolumes.getOrDefault(stream.clientId, 1.0); // Match the decoded stream to the line: duplicate mono across a stereo // line, fold a stereo (music) stream down onto a mono-only line. @@ -375,9 +386,12 @@ public final class DesktopVoiceOutput implements VoiceOutput { } /** Drop a speaker's pipeline entirely (e.g. they left the server). */ + /** Forgets a client that left; the server may hand its id to somebody else. */ public void removeClient(int clientId) { ClientStream s = streams.remove(clientId); if (s != null) s.close(); + mutedClients.remove(clientId); + clientVolumes.remove(clientId); } public void shutdown() { diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java index 0763f7e..2d588ba 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java @@ -1,5 +1,6 @@ package com.ts3client.ui; +import com.ts3client.contacts.ContactType; import com.ts3client.net.ChannelNode; import com.ts3client.net.ClientEntry; import com.ts3client.net.Group; @@ -50,6 +51,11 @@ final class ClientMenu { Icons.of(muted ? "PLAYER_ON" : "INPUT_MUTED")); mute.addActionListener(a -> actions.toggleClientMute(client)); menu.add(mute); + JMenuItem volume = new JMenuItem("Change Volume…", Icons.of("VOLUME")); + volume.addActionListener(a -> actions.changeClientVolume(client)); + menu.add(volume); + menu.addSeparator(); + addContactItems(menu, client, actions); } else { JMenuItem me = new JMenuItem("This is you", Icons.of("PLAYER_OFF")); me.setEnabled(false); @@ -84,6 +90,30 @@ final class ClientMenu { return menu; } + /** + * The contact entries, as TeamSpeak offers them: a client not on the list can be + * added as a friend or blocked; one on it can be edited or removed. + */ + private static void addContactItems(JPopupMenu menu, ClientEntry client, ServerTreePanel.Actions actions) { + ContactType type = actions.contactTypeOf(client); + if (type == null) { + JMenuItem friend = new JMenuItem("Add as Friend", Icons.of("ADD_FRIEND")); + friend.addActionListener(a -> actions.addContact(client, ContactType.FRIEND)); + menu.add(friend); + JMenuItem blocked = new JMenuItem("Add as Blocked", Icons.of("ADD_FOE")); + blocked.addActionListener(a -> actions.addContact(client, ContactType.BLOCKED)); + menu.add(blocked); + return; + } + JMenuItem edit = new JMenuItem("Edit in Contacts", Icons.of("EDIT_FRIEND_FOE_STATUS")); + edit.addActionListener(a -> actions.editContact(client)); + menu.add(edit); + JMenuItem remove = new JMenuItem("Remove from Contacts", + Icons.of(type == ContactType.BLOCKED ? "REMOVE_FOE" : "REMOVE_FRIEND")); + remove.addActionListener(a -> actions.removeContact(client)); + menu.add(remove); + } + /** * Whether the client's channel is actually rendered in the tree — it won't be if * we're not subscribed to it (e.g. it needs more subscribe power than we have). diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ClientVolumeDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ClientVolumeDialog.java new file mode 100644 index 0000000..754ce63 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ClientVolumeDialog.java @@ -0,0 +1,79 @@ +package com.ts3client.ui; + +import com.ts3client.net.ClientEntry; +import com.ts3client.net.TeamspeakConnection; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSlider; +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Frame; + +/** + * TeamSpeak's "Change Volume" for one client: a gain in decibels on top of the + * master volume, applied as the slider moves. It is remembered in the client's + * contact entry, when it has one; otherwise it lasts for the session. + */ +final class ClientVolumeDialog extends JDialog { + + private static final int RANGE_DB = 40; + + private final TeamspeakConnection conn; + private final ClientEntry client; + private final JSlider slider = new JSlider(-RANGE_DB, RANGE_DB, 0); + private final JLabel value = new JLabel(); + + ClientVolumeDialog(Frame owner, TeamspeakConnection conn, ClientEntry client) { + super(owner, "Change Volume: " + conn.displayName(client), false); + this.conn = conn; + this.client = client; + + slider.setValue((int) Math.round(conn.getClientVolumeModifier(client))); + slider.setMajorTickSpacing(20); + slider.setMinorTickSpacing(5); + slider.setPaintTicks(true); + slider.setPaintLabels(true); + slider.addChangeListener(e -> onChanged()); + FormPanel.limitWidth(value, 60); + + JPanel content = new JPanel(new BorderLayout(8, 8)); + content.setBorder(BorderFactory.createEmptyBorder(12, 12, 8, 12)); + content.add(new JLabel("Volume Modifier"), BorderLayout.NORTH); + content.add(FormPanel.sliderWithLabel(slider, value), BorderLayout.CENTER); + JLabel hint = new JLabel("Values greater than +6 dB will cause clipping " + + "and distortion that will negatively affect your audio quality. It is advised to choose " + + "lower values."); + hint.setForeground(Theme.chatSystem()); + content.add(hint, BorderLayout.SOUTH); + + JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + JButton reset = new JButton("Reset"); + reset.addActionListener(e -> slider.setValue(0)); + JButton close = new JButton("Close"); + close.addActionListener(e -> dispose()); + buttons.add(reset); + buttons.add(close); + + getContentPane().setLayout(new BorderLayout()); + getContentPane().add(content, BorderLayout.CENTER); + getContentPane().add(buttons, BorderLayout.SOUTH); + + onChanged(); + Dialogs.closeOnEscape(this); + setDefaultCloseOperation(DISPOSE_ON_CLOSE); + pack(); + setMinimumSize(new Dimension(360, getHeight())); + setLocationRelativeTo(owner); + } + + private void onChanged() { + int db = slider.getValue(); + value.setText((db > 0 ? "+" : "") + db + " dB"); + if (!slider.getValueIsAdjusting()) conn.setClientVolumeModifier(client, db); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ContactDefaultsDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ContactDefaultsDialog.java new file mode 100644 index 0000000..1c05ba3 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ContactDefaultsDialog.java @@ -0,0 +1,199 @@ +package com.ts3client.ui; + +import com.ts3client.config.Settings; +import com.ts3client.contacts.ContactDefaults; +import com.ts3client.contacts.ContactType; +import com.ts3client.contacts.NicknameDisplay; + +import javax.swing.BorderFactory; +import javax.swing.ButtonGroup; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JRadioButton; +import javax.swing.JSeparator; +import javax.swing.SwingConstants; +import java.awt.BorderLayout; +import java.awt.Dialog; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.util.EnumMap; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Function; + +/** + * TeamSpeak's "Contact Manager Default Settings": what a client starts out with + * when added as a friend, blocked or neutral — the ignore/hide flags and how its + * nickname is displayed — in a grid with one column per contact type. + */ +final class ContactDefaultsDialog extends JDialog { + + private static final ContactType[] COLUMNS = {ContactType.FRIEND, ContactType.BLOCKED, ContactType.NEUTRAL}; + + private final Settings settings; + private final Map> flags = new EnumMap<>(ContactType.class); + private final Map> displays = new EnumMap<>(ContactType.class); + + ContactDefaultsDialog(Dialog owner, Settings settings) { + super(owner, "Contact Manager Default Settings", true); + this.settings = settings; + for (ContactType t : COLUMNS) { + flags.put(t, new java.util.LinkedHashMap<>()); + displays.put(t, new EnumMap<>(NicknameDisplay.class)); + } + + JPanel content = new JPanel(); + content.setLayout(new javax.swing.BoxLayout(content, javax.swing.BoxLayout.Y_AXIS)); + content.setBorder(BorderFactory.createEmptyBorder(12, 12, 8, 12)); + JLabel intro = new JLabel("Configure default settings when adding a client to the Contact Manager"); + intro.setAlignmentX(LEFT_ALIGNMENT); + content.add(intro); + content.add(javax.swing.Box.createVerticalStrut(10)); + content.add(buildFlags()); + content.add(javax.swing.Box.createVerticalStrut(10)); + content.add(buildDisplays()); + + JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + JButton ok = new JButton("OK"); + ok.addActionListener(e -> { + apply(); + dispose(); + }); + JButton cancel = new JButton("Cancel"); + cancel.addActionListener(e -> dispose()); + buttons.add(ok); + buttons.add(cancel); + getRootPane().setDefaultButton(ok); + + getContentPane().setLayout(new BorderLayout()); + getContentPane().add(content, BorderLayout.CENTER); + getContentPane().add(buttons, BorderLayout.SOUTH); + + load(); + Dialogs.closeOnEscape(this); + setDefaultCloseOperation(DISPOSE_ON_CLOSE); + pack(); + setMinimumSize(new Dimension(getWidth(), getHeight())); + setLocationRelativeTo(owner); + } + + private JPanel buildFlags() { + JPanel grid = titledGrid("Ignore and Hide Defaults"); + int row = 1; + row = flagRow(grid, row, "Mute", "automute", "Automatically mute this person"); + row = flagRow(grid, row, "Ignore server/channel chat", "ignorePublicMessages", + "Ignore messages this person sends to the server or in channels"); + row = flagRow(grid, row, "Ignore private chat", "ignorePrivateMessages", "Ignore private chat by this person"); + row = flagRow(grid, row, "Ignore Pokes", "ignorePokes", "Ignore pokes this person sends you"); + row = flagRow(grid, row, "Hide avatar", "ignoreAvatar", "Do not download this person's avatar"); + row = flagRow(grid, row, "Hide away message", "ignoreAwayMessage", "Ignore this person's away messages"); + flagRow(grid, row, "Deny whisper", "ignoreWhisper", "This contact is always denied to whisper to you."); + return grid; + } + + private JPanel buildDisplays() { + JPanel grid = titledGrid("Nickname Display Defaults"); + Map groups = new EnumMap<>(ContactType.class); + for (ContactType t : COLUMNS) groups.put(t, new ButtonGroup()); + int row = 1; + for (NicknameDisplay display : NicknameDisplay.values()) { + GridBagConstraints c = cell(0, row); + c.anchor = GridBagConstraints.WEST; + grid.add(new JLabel(display.label()), c); + for (int i = 0; i < COLUMNS.length; i++) { + JRadioButton radio = new JRadioButton(); + radio.setToolTipText(display == NicknameDisplay.BOTH ? "Show both custom name and the nickname" + : display == NicknameDisplay.ONLY_CUSTOM ? "Show only custom name" : "Show only the nickname"); + groups.get(COLUMNS[i]).add(radio); + displays.get(COLUMNS[i]).put(display, radio); + grid.add(radio, cell(i + 1, row)); + } + row++; + } + return grid; + } + + /** A grid with the three contact types as column headings. */ + private JPanel titledGrid(String title) { + JPanel grid = new JPanel(new GridBagLayout()); + grid.setBorder(BorderFactory.createTitledBorder(title)); + grid.setAlignmentX(LEFT_ALIGNMENT); + String[] headings = {"Friends", "Blocked", "Neutral"}; + for (int i = 0; i < headings.length; i++) { + JLabel heading = new JLabel(headings[i], SwingConstants.CENTER); + heading.setFont(Theme.uiBold()); + GridBagConstraints c = cell(i + 1, 0); + c.ipadx = 12; + grid.add(heading, c); + } + GridBagConstraints line = cell(0, 100); + line.gridwidth = 4; + line.fill = GridBagConstraints.HORIZONTAL; + grid.add(new JSeparator(), line); + return grid; + } + + private int flagRow(JPanel grid, int row, String label, String key, String tooltip) { + GridBagConstraints c = cell(0, row); + c.anchor = GridBagConstraints.WEST; + c.weightx = 1; + grid.add(new JLabel(label), c); + for (int i = 0; i < COLUMNS.length; i++) { + JCheckBox box = new JCheckBox(); + box.setToolTipText(tooltip); + flags.get(COLUMNS[i]).put(key, box); + grid.add(box, cell(i + 1, row)); + } + return row + 1; + } + + private static GridBagConstraints cell(int x, int y) { + GridBagConstraints c = new GridBagConstraints(); + c.gridx = x; + c.gridy = y; + c.insets = new Insets(1, 6, 1, 6); + c.anchor = GridBagConstraints.CENTER; + return c; + } + + // ---- model <-> fields ---- + + private void load() { + for (ContactType t : COLUMNS) { + ContactDefaults.Entry e = settings.contactDefaults.of(t); + Map boxes = flags.get(t); + boxes.get("automute").setSelected(e.automute); + boxes.get("ignorePublicMessages").setSelected(e.ignorePublicMessages); + boxes.get("ignorePrivateMessages").setSelected(e.ignorePrivateMessages); + boxes.get("ignorePokes").setSelected(e.ignorePokes); + boxes.get("ignoreAvatar").setSelected(e.ignoreAvatar); + boxes.get("ignoreAwayMessage").setSelected(e.ignoreAwayMessage); + boxes.get("ignoreWhisper").setSelected(e.ignoreWhisper); + displays.get(t).get(e.nicknameDisplay).setSelected(true); + } + } + + private void apply() { + for (ContactType t : COLUMNS) { + ContactDefaults.Entry e = settings.contactDefaults.of(t); + Map boxes = flags.get(t); + e.automute = boxes.get("automute").isSelected(); + e.ignorePublicMessages = boxes.get("ignorePublicMessages").isSelected(); + e.ignorePrivateMessages = boxes.get("ignorePrivateMessages").isSelected(); + e.ignorePokes = boxes.get("ignorePokes").isSelected(); + e.ignoreAvatar = boxes.get("ignoreAvatar").isSelected(); + e.ignoreAwayMessage = boxes.get("ignoreAwayMessage").isSelected(); + e.ignoreWhisper = boxes.get("ignoreWhisper").isSelected(); + for (Map.Entry radio : displays.get(t).entrySet()) { + if (radio.getValue().isSelected()) e.nicknameDisplay = radio.getKey(); + } + } + settings.save(); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ContactsDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ContactsDialog.java new file mode 100644 index 0000000..43c8f55 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ContactsDialog.java @@ -0,0 +1,537 @@ +package com.ts3client.ui; + +import com.ts3client.config.Settings; +import com.ts3client.contacts.Contact; +import com.ts3client.contacts.ContactStore; +import com.ts3client.contacts.ContactType; +import com.ts3client.contacts.NicknameDisplay; +import com.ts3client.contacts.TeamSpeakSettingsDb; +import com.ts3client.text.TsLink; + +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.ButtonGroup; +import javax.swing.JButton; +import javax.swing.JCheckBox; +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.JRadioButton; +import javax.swing.JScrollPane; +import javax.swing.JTabbedPane; +import javax.swing.JTextField; +import javax.swing.JTree; +import javax.swing.KeyStroke; +import javax.swing.SwingUtilities; +import javax.swing.TransferHandler; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeCellRenderer; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreePath; +import javax.swing.tree.TreeSelectionModel; +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Frame; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.Toolkit; +import java.awt.datatransfer.DataFlavor; +import java.awt.datatransfer.StringSelection; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.io.File; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The contact list window, laid out like TeamSpeak's Contacts: the contacts grouped + * as friends, blocked and neutral on the left, and on the right the selected one's + * custom nickname and its General / Display / Ignore / Whisper pages. Every edit is + * stored as it is made. + * + *

Clients can be dragged in from the server tree — the drag carries the + * client's TS3 link, which names its unique id. + */ +final class ContactsDialog extends JDialog { + + private static final DateTimeFormatter SEEN = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + private static final Pattern CLIENT_LINK = Pattern.compile("client://[^\\]\\s]+"); + + private final ContactStore contacts; + private final Settings settings; + private final ContactStore.Listener storeListener = () -> SwingUtilities.invokeLater(this::reload); + + private final DefaultMutableTreeNode root = new DefaultMutableTreeNode(); + private final DefaultTreeModel treeModel = new DefaultTreeModel(root); + private final JTree tree = new JTree(treeModel); + private final DefaultMutableTreeNode friends = new DefaultMutableTreeNode("Friends"); + private final DefaultMutableTreeNode blocked = new DefaultMutableTreeNode("Blocked"); + private final DefaultMutableTreeNode neutral = new DefaultMutableTreeNode("Neutral"); + + private final JTextField nicknameField = new JTextField(18); + private final JButton nicknameApply = new JButton("Apply"); + private final JRadioButton neutralRadio = new JRadioButton("Neutral"); + private final JRadioButton blockedRadio = new JRadioButton("Blocked"); + private final JRadioButton friendRadio = new JRadioButton("Friend"); + private final JTextField phoneticField = new JTextField(18); + private final JRadioButton bothRadio = new JRadioButton(NicknameDisplay.BOTH.label()); + private final JRadioButton onlyCustomRadio = new JRadioButton(NicknameDisplay.ONLY_CUSTOM.label()); + private final JRadioButton onlyNicknameRadio = new JRadioButton(NicknameDisplay.ONLY_NICKNAME.label()); + private final JCheckBox automute = new JCheckBox("Mute"); + private final JCheckBox ignorePublic = new JCheckBox("Ignore server/channel chat"); + private final JCheckBox ignorePrivate = new JCheckBox("Ignore private chat"); + private final JCheckBox ignorePokes = new JCheckBox("Ignore Pokes"); + private final JCheckBox ignoreAvatar = new JCheckBox("Hide avatar"); + private final JCheckBox ignoreAway = new JCheckBox("Hide away message"); + private final JRadioButton allowWhisper = new JRadioButton("Allow whisper"); + private final JRadioButton denyWhisper = new JRadioButton("Deny whisper"); + private final JPanel details = new JPanel(new BorderLayout(0, 8)); + + /** Set while the fields are being filled from a contact, so that does not count as an edit. */ + private boolean filling; + + ContactsDialog(Frame owner, ContactStore contacts, Settings settings) { + super(owner, "Contacts", false); + this.contacts = contacts; + this.settings = settings; + + buildTree(); + JPanel left = new JPanel(new BorderLayout(0, 4)); + left.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 4)); + JLabel hint = new JLabel("Drag & Drop users here"); + hint.setForeground(Theme.chatSystem()); + left.add(hint, BorderLayout.NORTH); + JScrollPane scroll = new JScrollPane(tree); + scroll.setPreferredSize(new Dimension(300, 340)); + left.add(scroll, BorderLayout.CENTER); + + details.setBorder(BorderFactory.createEmptyBorder(8, 4, 8, 8)); + details.add(buildNicknameRow(), BorderLayout.NORTH); + details.add(buildPages(), BorderLayout.CENTER); + + JPanel buttons = new JPanel(new BorderLayout()); + buttons.setBorder(BorderFactory.createEmptyBorder(0, 8, 8, 8)); + JPanel leftButtons = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0)); + JButton defaults = new JButton("Set Defaults"); + defaults.setToolTipText("Set default display, ignore and hide options"); + defaults.addActionListener(e -> new ContactDefaultsDialog(this, settings).setVisible(true)); + leftButtons.add(defaults); + if (TeamSpeakSettingsDb.locate() != null) { + JButton importButton = new JButton("Import from TeamSpeak 3…"); + importButton.setToolTipText("Add the contacts of the TeamSpeak 3 client installed on this computer"); + importButton.addActionListener(e -> importFromTeamSpeak()); + leftButtons.add(importButton); + } + buttons.add(leftButtons, BorderLayout.WEST); + JButton close = new JButton("Close"); + close.addActionListener(e -> dispose()); + JPanel rightButtons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0)); + rightButtons.add(close); + buttons.add(rightButtons, BorderLayout.EAST); + + getContentPane().setLayout(new BorderLayout()); + getContentPane().add(left, BorderLayout.WEST); + getContentPane().add(details, BorderLayout.CENTER); + getContentPane().add(buttons, BorderLayout.SOUTH); + + contacts.addListener(storeListener); + addWindowListener(new java.awt.event.WindowAdapter() { + @Override + public void windowClosed(java.awt.event.WindowEvent e) { + contacts.removeListener(storeListener); + } + }); + + reload(); + Dialogs.closeOnEscape(this); + setDefaultCloseOperation(DISPOSE_ON_CLOSE); + pack(); + setMinimumSize(new Dimension(620, 400)); + setLocationRelativeTo(owner); + } + + // ---- tree ---- + + private void buildTree() { + root.add(friends); + root.add(blocked); + root.add(neutral); + tree.setRootVisible(false); + tree.setShowsRootHandles(true); + tree.setRowHeight(20); + tree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION); + tree.setCellRenderer(new ContactRenderer()); + tree.addTreeSelectionListener(e -> showSelected()); + tree.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + maybePopup(e); + } + + @Override + public void mouseReleased(MouseEvent e) { + maybePopup(e); + } + }); + tree.getInputMap(JComponent.WHEN_FOCUSED).put(KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, 0), "remove"); + tree.getActionMap().put("remove", new javax.swing.AbstractAction() { + @Override + public void actionPerformed(java.awt.event.ActionEvent e) { + removeSelected(); + } + }); + tree.setTransferHandler(new DropHandler()); + } + + /** Re-lists the contacts, keeping the selection on the same unique id. */ + private void reload() { + String selectedUid = selectedContact() == null ? null : selectedContact().uniqueId; + for (DefaultMutableTreeNode group : List.of(friends, blocked, neutral)) group.removeAllChildren(); + List sorted = new ArrayList<>(contacts.all()); + sorted.sort(Comparator.comparing((Contact c) -> c.nickname, String.CASE_INSENSITIVE_ORDER) + .thenComparing(c -> c.uniqueId)); + for (Contact c : sorted) groupOf(c.type).add(new DefaultMutableTreeNode(c)); + treeModel.reload(); + for (int i = 0; i < tree.getRowCount(); i++) tree.expandRow(i); + if (selectedUid != null) select(selectedUid); + else showSelected(); + } + + private DefaultMutableTreeNode groupOf(ContactType type) { + switch (type) { + case FRIEND: + return friends; + case BLOCKED: + return blocked; + default: + return neutral; + } + } + + /** Selects the contact with a unique id, if it is on the list. */ + void select(String uniqueId) { + java.util.Enumeration nodes = root.depthFirstEnumeration(); + while (nodes.hasMoreElements()) { + DefaultMutableTreeNode n = (DefaultMutableTreeNode) nodes.nextElement(); + if (n.getUserObject() instanceof Contact && ((Contact) n.getUserObject()).uniqueId.equals(uniqueId)) { + TreePath path = new TreePath(n.getPath()); + tree.setSelectionPath(path); + tree.scrollPathToVisible(path); + return; + } + } + } + + private Contact selectedContact() { + TreePath path = tree.getSelectionPath(); + if (path == null) return null; + Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); + return obj instanceof Contact ? (Contact) obj : null; + } + + private void maybePopup(MouseEvent e) { + if (!e.isPopupTrigger()) return; + TreePath path = tree.getPathForLocation(e.getX(), e.getY()); + if (path == null) return; + tree.setSelectionPath(path); + Contact contact = selectedContact(); + if (contact == null) return; + + JPopupMenu menu = new JPopupMenu(); + for (ContactType type : new ContactType[]{ContactType.FRIEND, ContactType.BLOCKED, ContactType.NEUTRAL}) { + JMenuItem item = new javax.swing.JRadioButtonMenuItem(type.label(), type == contact.type); + item.addActionListener(a -> edit(c -> c.type = type)); + menu.add(item); + } + menu.addSeparator(); + JMenuItem remove = new JMenuItem("Remove", Icons.of("DELETE")); + remove.addActionListener(a -> removeSelected()); + menu.add(remove); + JMenuItem applyDefaults = new JMenuItem("Apply Defaults"); + applyDefaults.addActionListener(a -> edit(c -> c.applyDefaults(settings.contactDefaults.of(c.type)))); + menu.add(applyDefaults); + menu.addSeparator(); + JMenuItem copyUid = new JMenuItem("Copy Unique ID to Clipboard", Icons.of("COPY")); + copyUid.addActionListener(a -> Toolkit.getDefaultToolkit().getSystemClipboard() + .setContents(new StringSelection(contact.uniqueId), null)); + menu.add(copyUid); + menu.show(tree, e.getX(), e.getY()); + } + + private void removeSelected() { + Contact contact = selectedContact(); + if (contact == null) return; + int answer = JOptionPane.showConfirmDialog(this, + "Do you really want to delete contact " + contact.nickname + "?", "Remove Contact", + JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE); + if (answer == JOptionPane.YES_OPTION) contacts.remove(contact.uniqueId); + } + + private void importFromTeamSpeak() { + File db = TeamSpeakSettingsDb.locate(); + if (db == null) return; + try { + List imported = TeamSpeakSettingsDb.readContacts(db); + int added = contacts.merge(imported); + JOptionPane.showMessageDialog(this, imported.size() + " contact(s) read from " + db + + "\n" + added + " new, " + (imported.size() - added) + " updated.", + "Import from TeamSpeak 3", JOptionPane.INFORMATION_MESSAGE); + } catch (Exception e) { + JOptionPane.showMessageDialog(this, "Could not read " + db + ":\n" + e.getMessage(), + "Import from TeamSpeak 3", JOptionPane.ERROR_MESSAGE); + } + } + + // ---- details ---- + + private JComponent buildNicknameRow() { + JPanel row = new JPanel(new GridBagLayout()); + GridBagConstraints c = new GridBagConstraints(); + c.insets = new Insets(2, 2, 2, 2); + c.anchor = GridBagConstraints.WEST; + c.gridy = 0; + c.gridx = 0; + row.add(new JLabel("Custom Nickname:"), c); + c.gridx = 1; + c.weightx = 1; + c.fill = GridBagConstraints.HORIZONTAL; + nicknameField.setToolTipText("Enter custom nickname"); + nicknameField.addActionListener(e -> applyNickname()); + row.add(nicknameField, c); + c.gridx = 2; + c.weightx = 0; + c.fill = GridBagConstraints.NONE; + nicknameApply.setToolTipText("Apply nickname change"); + nicknameApply.addActionListener(e -> applyNickname()); + row.add(nicknameApply, c); + return row; + } + + private JComponent buildPages() { + JTabbedPane pages = new JTabbedPane(); + pages.addTab("General", page( + radioGroup(neutralRadio, blockedRadio, friendRadio), + Box.createVerticalStrut(8), + labelled("Phonetic Nickname:", phoneticField))); + neutralRadio.setToolTipText("Mark as neutral"); + blockedRadio.setToolTipText("Mark as blocked"); + friendRadio.setToolTipText("Mark as friend"); + phoneticField.setToolTipText("Client nickname written as phonemes (optional)"); + neutralRadio.addActionListener(e -> edit(c -> c.type = ContactType.NEUTRAL)); + blockedRadio.addActionListener(e -> edit(c -> c.type = ContactType.BLOCKED)); + friendRadio.addActionListener(e -> edit(c -> c.type = ContactType.FRIEND)); + phoneticField.addActionListener(e -> applyPhonetic()); + phoneticField.addFocusListener(new java.awt.event.FocusAdapter() { + @Override + public void focusLost(java.awt.event.FocusEvent e) { + applyPhonetic(); + } + }); + + pages.addTab("Display", page(radioGroup(bothRadio, onlyCustomRadio, onlyNicknameRadio))); + bothRadio.setToolTipText("Show custom name and nickname"); + onlyCustomRadio.setToolTipText("Show only custom name"); + onlyNicknameRadio.setToolTipText("Show nickname only"); + bothRadio.addActionListener(e -> edit(c -> c.nicknameDisplay = NicknameDisplay.BOTH)); + onlyCustomRadio.addActionListener(e -> edit(c -> c.nicknameDisplay = NicknameDisplay.ONLY_CUSTOM)); + onlyNicknameRadio.addActionListener(e -> edit(c -> c.nicknameDisplay = NicknameDisplay.ONLY_NICKNAME)); + + pages.addTab("Ignore", page(automute, ignorePublic, ignorePrivate, ignorePokes, ignoreAvatar, ignoreAway)); + automute.setToolTipText("Automatically mute this person"); + ignorePublic.setToolTipText("Ignore messages this person sends to the server or in channels"); + ignorePrivate.setToolTipText("Ignore private chat by this person"); + ignorePokes.setToolTipText("Ignore pokes this person sends you"); + ignoreAvatar.setToolTipText("Do not download this person's avatar"); + ignoreAway.setToolTipText("Ignore this person's away messages"); + automute.addActionListener(e -> edit(c -> c.automute = automute.isSelected())); + ignorePublic.addActionListener(e -> edit(c -> c.ignorePublicMessages = ignorePublic.isSelected())); + ignorePrivate.addActionListener(e -> edit(c -> c.ignorePrivateMessages = ignorePrivate.isSelected())); + ignorePokes.addActionListener(e -> edit(c -> c.ignorePokes = ignorePokes.isSelected())); + ignoreAvatar.addActionListener(e -> edit(c -> c.ignoreAvatar = ignoreAvatar.isSelected())); + ignoreAway.addActionListener(e -> edit(c -> c.ignoreAwayMessage = ignoreAway.isSelected())); + + pages.addTab("Whisper", page(radioGroup(allowWhisper, denyWhisper))); + allowWhisper.setToolTipText("This contact is allowed to whisper to you"); + denyWhisper.setToolTipText("This contact is always denied to whisper to you."); + allowWhisper.addActionListener(e -> edit(c -> c.whisperAllowed = true)); + denyWhisper.addActionListener(e -> edit(c -> c.whisperAllowed = false)); + return pages; + } + + private static JPanel page(Component... rows) { + JPanel p = new JPanel(); + p.setLayout(new BoxLayout(p, BoxLayout.Y_AXIS)); + p.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + for (Component row : rows) { + if (row instanceof JComponent) ((JComponent) row).setAlignmentX(Component.LEFT_ALIGNMENT); + p.add(row); + } + p.add(Box.createVerticalGlue()); + return p; + } + + private static JPanel radioGroup(JRadioButton... buttons) { + ButtonGroup group = new ButtonGroup(); + JPanel p = new JPanel(); + p.setLayout(new BoxLayout(p, BoxLayout.Y_AXIS)); + for (JRadioButton b : buttons) { + group.add(b); + b.setAlignmentX(Component.LEFT_ALIGNMENT); + p.add(b); + } + return p; + } + + private static JPanel labelled(String label, JComponent field) { + JPanel p = new JPanel(new BorderLayout(6, 0)); + p.add(new JLabel(label), BorderLayout.WEST); + p.add(field, BorderLayout.CENTER); + p.setMaximumSize(new Dimension(Integer.MAX_VALUE, field.getPreferredSize().height)); + return p; + } + + /** Fills the right-hand side from the selected contact, or greys it out. */ + private void showSelected() { + Contact c = selectedContact(); + filling = true; + try { + setDetailsEnabled(details, c != null); + if (c == null) { + nicknameField.setText(""); + phoneticField.setText(""); + return; + } + nicknameField.setText(c.nickname); + phoneticField.setText(c.phoneticNickname); + (c.type == ContactType.FRIEND ? friendRadio : c.type == ContactType.BLOCKED ? blockedRadio : neutralRadio) + .setSelected(true); + (c.nicknameDisplay == NicknameDisplay.ONLY_CUSTOM ? onlyCustomRadio + : c.nicknameDisplay == NicknameDisplay.ONLY_NICKNAME ? onlyNicknameRadio : bothRadio) + .setSelected(true); + automute.setSelected(c.automute); + ignorePublic.setSelected(c.ignorePublicMessages); + ignorePrivate.setSelected(c.ignorePrivateMessages); + ignorePokes.setSelected(c.ignorePokes); + ignoreAvatar.setSelected(c.ignoreAvatar); + ignoreAway.setSelected(c.ignoreAwayMessage); + (c.whisperAllowed ? allowWhisper : denyWhisper).setSelected(true); + } finally { + filling = false; + } + } + + private static void setDetailsEnabled(Component component, boolean enabled) { + component.setEnabled(enabled); + if (component instanceof java.awt.Container) { + for (Component child : ((java.awt.Container) component).getComponents()) { + setDetailsEnabled(child, enabled); + } + } + } + + /** Changes the selected contact and stores it; the store's change notice re-lists the tree. */ + private void edit(Consumer change) { + if (filling) return; + Contact c = selectedContact(); + if (c == null) return; + change.accept(c); + contacts.put(c); + } + + private void applyNickname() { + String name = nicknameField.getText().trim(); + Contact c = selectedContact(); + if (c == null || name.isEmpty() || name.equals(c.nickname)) return; + edit(contact -> contact.nickname = name); + } + + private void applyPhonetic() { + String name = phoneticField.getText().trim(); + Contact c = selectedContact(); + if (c == null || name.equals(c.phoneticNickname)) return; + edit(contact -> contact.phoneticNickname = name); + } + + // ---- drop target ---- + + /** Accepts clients dragged out of a server tree, whose transfer text is their TS3 link. */ + private final class DropHandler extends TransferHandler { + @Override + public boolean canImport(TransferSupport support) { + return support.isDataFlavorSupported(DataFlavor.stringFlavor); + } + + @Override + public boolean importData(TransferSupport support) { + String text; + try { + text = (String) support.getTransferable().getTransferData(DataFlavor.stringFlavor); + } catch (Exception e) { + return false; + } + boolean added = false; + Matcher m = CLIENT_LINK.matcher(text); + while (m.find()) { + TsLink.Ref ref = TsLink.parse(m.group()); + if (ref == null || !ref.client || ref.uniqueId.isEmpty()) continue; + if (contacts.find(ref.uniqueId) == null) { + contacts.add(ref.uniqueId, ref.name, ContactType.NEUTRAL, settings.contactDefaults); + } + select(ref.uniqueId); + added = true; + } + return added; + } + } + + // ---- rendering ---- + + private static final class ContactRenderer extends DefaultTreeCellRenderer { + @Override + public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel, boolean expanded, + boolean leaf, int row, boolean hasFocus) { + super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus); + Object obj = ((DefaultMutableTreeNode) value).getUserObject(); + if (obj instanceof Contact) { + Contact c = (Contact) obj; + setText("" + esc(c.nickname) + "  " + esc(lastSeen(c)) + ""); + setIcon(Icons.clientIdle()); + setFont(Theme.uiFont()); + } else { + setText(String.valueOf(obj)); + setIcon(null); + setFont(Theme.uiBold()); + } + return this; + } + + private static String lastSeen(Contact c) { + if (c.lastSeen == null) return "Never"; + String where = c.lastSeenServerName.isEmpty() ? c.lastSeenServerAddress : c.lastSeenServerName; + return where.isEmpty() ? SEEN.format(c.lastSeen) : SEEN.format(c.lastSeen) + " on " + where; + } + + private static String esc(String s) { + return s.replace("&", "&").replace("<", "<").replace(">", ">"); + } + + private static String hex(java.awt.Color c) { + return String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), c.getBlue()); + } + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ContactsPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ContactsPanel.java new file mode 100644 index 0000000..f1e74e2 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ContactsPanel.java @@ -0,0 +1,89 @@ +package com.ts3client.ui; + +import com.ts3client.config.Settings; + +import javax.swing.BorderFactory; +import javax.swing.DefaultListCellRenderer; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.SwingUtilities; +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; + +/** + * Options page for the contact list: whether friends and blocked clients are + * coloured in the server tree, whose whispers get through (TeamSpeak's whisper + * options), and the defaults a new contact starts with. + */ +final class ContactsPanel extends JPanel { + + private final Settings settings; + private final JCheckBox colorContacts = new JCheckBox("Colour friends and blocked clients in the server tree"); + private final JComboBox whisperPolicy = new JComboBox<>(Settings.WhisperPolicy.values()); + + ContactsPanel(Settings settings) { + super(new BorderLayout()); + this.settings = settings; + setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + + JPanel p = new JPanel(new GridBagLayout()); + GridBagConstraints c = new GridBagConstraints(); + c.insets = new Insets(2, 2, 2, 2); + c.anchor = GridBagConstraints.WEST; + c.fill = GridBagConstraints.HORIZONTAL; + c.gridx = 0; + c.gridwidth = 2; + + c.gridy = 0; + colorContacts.setSelected(settings.colorContacts); + colorContacts.setToolTipText("Friends in green and blocked clients in red, as the TeamSpeak client shows them"); + p.add(colorContacts, c); + + c.gridy = 1; + c.insets = new Insets(12, 2, 2, 2); + p.add(new JLabel("Whispers: who is allowed to whisper to you"), c); + c.gridy = 2; + c.insets = new Insets(2, 2, 2, 2); + whisperPolicy.setSelectedItem(settings.whisperPolicy); + whisperPolicy.setRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, + boolean selected, boolean focus) { + super.getListCellRendererComponent(list, value, index, selected, focus); + if (value instanceof Settings.WhisperPolicy) setText(((Settings.WhisperPolicy) value).label()); + return this; + } + }); + whisperPolicy.setToolTipText("Use individual settings in contacts, else allow: a client not on your " + + "contact list may whisper to you.
Use individual settings in contacts, else deny: only " + + "contacts marked \"Allow whisper\" may.
Nobody: individual contact settings are ignored."); + p.add(whisperPolicy, c); + + c.gridy = 3; + c.insets = new Insets(12, 2, 2, 2); + c.fill = GridBagConstraints.NONE; + JButton defaults = new JButton("Contact defaults…"); + defaults.setToolTipText("Configure default settings when adding a client to the Contact Manager"); + defaults.addActionListener(e -> { + java.awt.Window owner = SwingUtilities.getWindowAncestor(this); + new ContactDefaultsDialog(owner instanceof JDialog ? (JDialog) owner : null, settings).setVisible(true); + }); + p.add(defaults, c); + + add(p, BorderLayout.NORTH); + } + + /** Copies the page into the settings; the caller saves them. */ + void apply() { + settings.colorContacts = colorContacts.isSelected(); + settings.whisperPolicy = (Settings.WhisperPolicy) whisperPolicy.getSelectedItem(); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java index b253e0a..a959cbf 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java @@ -1,5 +1,6 @@ package com.ts3client.ui; +import com.ts3client.contacts.Contact; import com.ts3client.net.ChannelNode; import com.ts3client.net.ClientEntry; import com.ts3client.net.Group; @@ -50,6 +51,7 @@ public final class InfoPanel extends JPanel { private ClientEntry shownClient; private ServerModel shownModel; private IconRepository shownIcons; + private Contact shownContact; private boolean inChatTab; public InfoPanel() { @@ -144,11 +146,12 @@ public final class InfoPanel extends JPanel { render(); } - public void showClient(ClientEntry cl, ServerModel model, IconRepository icons) { + public void showClient(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact) { shownClient = cl; shownChannel = null; shownModel = model; shownIcons = icons; + shownContact = contact; render(); } @@ -166,7 +169,7 @@ public final class InfoPanel extends JPanel { private String body() { if (shownChannel != null) return channelBody(shownChannel); - if (shownClient != null) return clientBody(shownClient, shownModel, shownIcons); + if (shownClient != null) return clientBody(shownClient, shownModel, shownIcons, shownContact); return "Select a channel or client to see details."; } @@ -187,9 +190,15 @@ public final class InfoPanel extends JPanel { return sb.toString(); } - private static String clientBody(ClientEntry cl, ServerModel model, IconRepository icons) { + private static String clientBody(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact) { StringBuilder sb = new StringBuilder(); - sb.append(heading(esc(cl.nickname) + (cl.self ? " (you)" : ""))); + String name = contact == null ? cl.nickname : contact.displayName(cl.nickname); + sb.append(heading(esc(name) + (cl.self ? " (you)" : ""))); + if (contact != null) { + String type = contact.type.label(); + if (!contact.phoneticNickname.isEmpty()) type += " (" + esc(contact.phoneticNickname) + ")"; + row(sb, "Contact", type); + } List serverGroups = model.serverGroupsOf(cl.serverGroupIds); if (serverGroups.isEmpty()) { diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index 5d8d1a9..9e0b1e8 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -8,6 +8,7 @@ import com.ts3client.config.Bookmark; import com.ts3client.config.Bookmarks; import com.ts3client.config.IdentityStore; import com.ts3client.config.Settings; +import com.ts3client.contacts.ContactStore; import com.ts3client.net.ChannelNode; import com.ts3client.sound.SoundNotifier; import com.ts3client.sound.SoundPlayer; @@ -46,6 +47,8 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { private final Bookmarks bookmarks = Bookmarks.load(); private final AwayMessages awayMessages = AwayMessages.load(); private final IdentityStore identities; + /** Friends and blocked clients, shared by every connection. */ + private final ContactStore contacts = ContactStore.load(); private final AudioBackend audio = new DesktopAudioBackend(); /** Sound pack playback, shared by every connection. */ private final SoundNotifier sounds; @@ -86,6 +89,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { this.soundPlayer = audio.createSoundPlayer(settings); this.sounds.setPlayer(soundPlayer); this.hotkeys = new HotkeyService(new HotkeyActions(this), this::hotkeyArgumentChoices); + contacts.addListener(() -> SwingUtilities.invokeLater(this::contactsChanged)); setIconImage(Icons.app().getImage()); // We tear the connections down ourselves on close, so don't let Swing kill the JVM. @@ -193,6 +197,11 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { showIdentities(); } + @Override + public void onShowContacts() { + showContacts(null); + } + @Override public boolean canViewBanList() { return selected != null && selected.canViewBanList(); @@ -310,7 +319,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { // ---- tab management ---- private ServerTab newTab() { - ServerTab tab = new ServerTab(this, settings, identities, audio, sounds); + ServerTab tab = new ServerTab(this, settings, identities, audio, sounds, contacts); tabs.add(tab); tabPane.addTab(tab); refreshTabs(); @@ -765,6 +774,32 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { new IdentitiesDialog(this, identities, settings, bookmarks, null).setVisible(true); } + Settings settings() { + return settings; + } + + /** The one contacts window, kept so a second request brings it forward instead of doubling it. */ + private ContactsDialog contactsDialog; + + /** + * Opens the contact list, or brings it forward. + * + * @param uniqueId the contact to select, or {@code null} to keep the current selection + */ + void showContacts(String uniqueId) { + if (contactsDialog == null || !contactsDialog.isDisplayable()) { + contactsDialog = new ContactsDialog(this, contacts, settings); + } + if (uniqueId != null) contactsDialog.select(uniqueId); + contactsDialog.setVisible(true); + contactsDialog.toFront(); + } + + /** The contact list changed somewhere: every open server follows. */ + private void contactsChanged() { + for (ServerTab tab : tabs) tab.contactsChanged(); + } + private void showSettings() { // Device and voice-gating changes apply to the capturing connection and the // playback of the visible one; the rest pick the new settings up on connect. @@ -772,7 +807,10 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { micTab == null ? null : micTab.connection().getMicrophone(), selected == null ? null : selected.connection().getPlayback(), sounds, hotkeys, - this::applyOutputSettingsToAllTabs); + () -> { + applyOutputSettingsToAllTabs(); + contactsChanged(); + }); dlg.setVisible(true); } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java index 238da5f..b48a883 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java @@ -48,6 +48,8 @@ final class MainMenuBar extends JMenuBar { void onShowIdentities(); + void onShowContacts(); + /** Whether the current server lets us read its ban list; asked as the Tools menu opens. */ boolean canViewBanList(); @@ -123,12 +125,15 @@ final class MainMenuBar extends JMenuBar { JMenu tools = new JMenu("Tools"); JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER")); identitiesItem.addActionListener(e -> listener.onShowIdentities()); + JMenuItem contactsItem = new JMenuItem("Contacts…", Icons.ofAny("CONTACT", "EDIT_FRIEND_FOE_STATUS")); + contactsItem.addActionListener(e -> listener.onShowContacts()); banListItem = new JMenuItem("Ban List", Icons.of("BAN_LIST")); banListItem.addActionListener(e -> listener.onShowBanList()); JMenuItem options = new JMenuItem("Options…", Icons.of("SETTINGS")); options.addActionListener(e -> listener.onShowSettings()); JPopupMenu.Separator banListSeparator = new JPopupMenu.Separator(); tools.add(identitiesItem); + tools.add(contactsItem); tools.addSeparator(); tools.add(banListItem); tools.add(banListSeparator); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java index 9cb7e2e..f7e700f 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java @@ -5,6 +5,7 @@ import com.ts3client.config.IdentityEntry; import com.ts3client.config.IdentityStore; import com.ts3client.config.BanReasonPresets; import com.ts3client.config.Settings; +import com.ts3client.contacts.ContactStore; import com.ts3client.net.ChannelNode; import com.ts3client.net.ClientEntry; import com.ts3client.net.ServerModel; @@ -64,7 +65,7 @@ final class ServerTab implements ServerTabConnectionEvents.Listener { private BanListDialog banList; ServerTab(MainFrame host, Settings settings, IdentityStore identities, AudioBackend audio, - SoundNotifier sounds) { + SoundNotifier sounds, ContactStore contacts) { this.host = host; this.settings = settings; this.identities = identities; @@ -74,12 +75,12 @@ final class ServerTab implements ServerTabConnectionEvents.Listener { // panels and the other tab helpers that read from it — so wiring finishes with // an explicit attach() once everything is built. Nothing fires callbacks before then. this.events = new ServerTabConnectionEvents(host, this, this); - this.conn = new TeamspeakConnection(settings, audio, events, sounds); + this.conn = new TeamspeakConnection(settings, audio, events, sounds, contacts); this.groupIcons = new GroupIcons(conn.getIcons()); this.selfState = new ServerTabSelfState(conn); this.chatPanel = new ChatPanel(); this.treeActions = new ServerTabTreeActions(host, this, conn, chatPanel, infoPanel, groupIcons); - this.treePanel = new ServerTreePanel(conn.getModel(), groupIcons, treeActions); + this.treePanel = new ServerTreePanel(conn.getModel(), groupIcons, contacts, settings, treeActions); treeActions.attach(treePanel); events.attach(conn, treePanel, chatPanel, selfState, treeActions); @@ -298,6 +299,13 @@ final class ServerTab implements ServerTabConnectionEvents.Listener { else banList.connectionLost(); } + /** The contact list changed: names, colours and per-client playback follow. */ + void contactsChanged() { + conn.applyContacts(); + treePanel.refreshRowSizes(); + treeActions.renderInfo(); + } + /** Releases the background resources of a tab that is being thrown away. */ void dispose() { conn.getIcons().shutdown(); @@ -388,7 +396,7 @@ final class ServerTab implements ServerTabConnectionEvents.Listener { private String peerName(int clientId) { ClientEntry c = conn.getModel().getClient(clientId); - return c != null ? c.nickname : "Client " + clientId; + return c != null ? conn.displayName(c) : "Client " + clientId; } /** Joins the channel at a "/"-separated path, as the hotkey action names it. */ diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java index 896f0d5..7de44e6 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java @@ -132,7 +132,8 @@ final class ServerTabConnectionEvents implements ConnectionListener { } @Override - public void onChat(ChatScope scope, int fromClientId, String fromUniqueId, String fromName, String message) { + public void onChat(ChatScope scope, int fromClientId, String fromUniqueId, String nickname, String message) { + String fromName = conn.getContacts().displayName(fromUniqueId, nickname); SwingUtilities.invokeLater(() -> { switch (scope) { case PRIVATE: diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java index c3f6155..2c6e466 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java @@ -1,5 +1,6 @@ package com.ts3client.ui; +import com.ts3client.contacts.ContactType; import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelSettings; import com.ts3client.net.ClientEntry; @@ -59,7 +60,8 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions { if (sel instanceof ChannelNode) { infoPanel.showChannel((ChannelNode) sel); } else if (sel instanceof ClientEntry) { - infoPanel.showClient((ClientEntry) sel, conn.getModel(), conn.getIcons()); + infoPanel.showClient((ClientEntry) sel, conn.getModel(), conn.getIcons(), + conn.getContacts().find(((ClientEntry) sel).uniqueId)); } else { infoPanel.clear(); } @@ -123,7 +125,7 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions { @Override public void openPrivateChat(ClientEntry client) { - chatPanel.openPrivateChat(client.id, client.uniqueId, client.nickname); + chatPanel.openPrivateChat(client.id, client.uniqueId, conn.displayName(client)); conn.loadPrivateChatHistory(client.id, client.uniqueId); } @@ -230,6 +232,32 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions { })); } + @Override + public void addContact(ClientEntry client, ContactType type) { + conn.getContacts().add(client.uniqueId, client.nickname, type, host.settings().contactDefaults); + } + + @Override + public void editContact(ClientEntry client) { + host.showContacts(client.uniqueId); + } + + @Override + public void removeContact(ClientEntry client) { + conn.getContacts().remove(client.uniqueId); + } + + @Override + public ContactType contactTypeOf(ClientEntry client) { + return conn.getContacts().typeOf(client.uniqueId); + } + + @Override + public void changeClientVolume(ClientEntry client) { + if (!conn.isConnected()) return; + new ClientVolumeDialog(host, conn, client).setVisible(true); + } + @Override public void renameSelf(String nickname) { host.changeNickname(nickname); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeCellRenderer.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeCellRenderer.java index 4ded35a..993a6ee 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeCellRenderer.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeCellRenderer.java @@ -1,5 +1,8 @@ package com.ts3client.ui; +import com.ts3client.config.Settings; +import com.ts3client.contacts.Contact; +import com.ts3client.contacts.ContactStore; import com.ts3client.net.ChannelNode; import com.ts3client.net.ClientEntry; @@ -7,6 +10,7 @@ import javax.swing.ImageIcon; import javax.swing.JTree; import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.DefaultTreeCellRenderer; +import java.awt.Color; import java.awt.Component; import java.util.function.IntPredicate; @@ -18,9 +22,13 @@ final class ServerTreeCellRenderer extends DefaultTreeCellRenderer { /** Whether a client id is our own, which the panel learns on connecting. */ private final IntPredicate isSelf; + private final ContactStore contacts; + private final Settings settings; - ServerTreeCellRenderer(IntPredicate isSelf) { + ServerTreeCellRenderer(IntPredicate isSelf, ContactStore contacts, Settings settings) { this.isSelf = isSelf; + this.contacts = contacts; + this.settings = settings; } @Override @@ -49,15 +57,18 @@ final class ServerTreeCellRenderer extends DefaultTreeCellRenderer { } } else if (obj instanceof ClientEntry) { ClientEntry cl = (ClientEntry) obj; - String label = cl.nickname; - if (cl.away && !cl.awayMessage.isEmpty()) label += " [" + cl.awayMessage + "]"; + Contact contact = contacts.find(cl.uniqueId); + String label = contact == null ? cl.nickname : contact.displayName(cl.nickname); + if (cl.away && !cl.awayMessage.isEmpty() && (contact == null || !contact.ignoreAwayMessage)) { + label += " [" + cl.awayMessage + "]"; + } setText(label); setIcon(iconFor(cl)); // Talking shows in the client's icon, as it does in TeamSpeak. Marking the // name as well would also re-measure the row mid-speech, which leaves the // nickname clipped to an ellipsis for as long as it lasts. Our own name is // bold throughout, so its width never changes either. - setForeground(Theme.treeText()); + setForeground(contactColor(contact)); setFont(isSelf.test(cl.id) ? Theme.uiBold() : Theme.uiFont()); } else { // root / server @@ -69,6 +80,19 @@ final class ServerTreeCellRenderer extends DefaultTreeCellRenderer { return this; } + /** Friends and blocked clients stand out by colour, as in TeamSpeak, unless turned off. */ + private Color contactColor(Contact contact) { + if (contact == null || !settings.colorContacts) return Theme.treeText(); + switch (contact.type) { + case FRIEND: + return Theme.friend(); + case BLOCKED: + return Theme.blocked(); + default: + return Theme.treeText(); + } + } + private ImageIcon iconFor(ChannelNode c) { if (c.hasPassword) return Icons.channelLocked(c.subscribed); if (c.maxClients >= 0 && c.clients.size() >= c.maxClients) return Icons.channelFull(c.subscribed); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java index 3237942..a0a67e2 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java @@ -1,5 +1,8 @@ package com.ts3client.ui; +import com.ts3client.config.Settings; +import com.ts3client.contacts.ContactStore; +import com.ts3client.contacts.ContactType; import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelSettings; import com.ts3client.net.ClientEntry; @@ -106,6 +109,20 @@ public final class ServerTreePanel extends JScrollPane { /** A new nickname for ourselves, typed into the tree row. */ void renameSelf(String nickname); + + /** Files a client in the contact list as a friend, blocked or neutral. */ + void addContact(ClientEntry client, ContactType type); + + /** Opens the contact list at this client's entry. */ + void editContact(ClientEntry client); + + void removeContact(ClientEntry client); + + /** The type a client is filed under, or {@code null} for a client that is no contact. */ + ContactType contactTypeOf(ClientEntry client); + + /** Opens the per-client volume dialog. */ + void changeClientVolume(ClientEntry client); } private final DropIndicatorTree tree; @@ -134,7 +151,8 @@ public final class ServerTreePanel extends JScrollPane { private final NicknameCellEditor nicknameEditor; private int selfClientId = -1; - public ServerTreePanel(ServerModel model, GroupIcons groupIcons, Actions actions) { + public ServerTreePanel(ServerModel model, GroupIcons groupIcons, ContactStore contacts, Settings settings, + Actions actions) { this.model = model; this.groupIcons = groupIcons; this.actions = actions; @@ -153,7 +171,7 @@ public final class ServerTreePanel extends JScrollPane { tree.setRowHeight(20); tree.setBackground(Theme.treeBg()); tree.setFont(Theme.uiFont()); - ServerTreeCellRenderer renderer = new ServerTreeCellRenderer(id -> id == selfClientId); + ServerTreeCellRenderer renderer = new ServerTreeCellRenderer(id -> id == selfClientId, contacts, settings); tree.setCellRenderer(renderer); nicknameEditor = NicknameCellEditor.create(tree, renderer, id -> id == selfClientId); tree.setCellEditor(nicknameEditor); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java index 37617cb..3707957 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java @@ -35,6 +35,7 @@ public final class SettingsDialog extends JDialog { private final DesignPanel designPanel; private final HotkeysPanel hotkeysPanel; private final ClientVersionPanel clientVersionPanel; + private final ContactsPanel contactsPanel; public SettingsDialog(Frame owner, Settings settings, VoiceInput liveMic, VoiceOutput livePlayback, @@ -49,6 +50,7 @@ public final class SettingsDialog extends JDialog { designPanel = new DesignPanel(settings); hotkeysPanel = new HotkeysPanel(hotkeys); clientVersionPanel = new ClientVersionPanel(settings); + contactsPanel = new ContactsPanel(settings); devicesPanel = new DevicesPanel(settings, livePlayback, this::applyLive, this::restartTest, this::setTestOutputDevice); voiceActivationPanel = new VoiceActivationPanel(settings, liveMic, hotkeys, @@ -60,6 +62,7 @@ public final class SettingsDialog extends JDialog { tabs.addTab("Notifications", notificationsPanel); tabs.addTab("Design", designPanel); tabs.addTab("Hotkeys", hotkeysPanel); + tabs.addTab("Contacts", contactsPanel); tabs.addTab("Client Version", scrollable(clientVersionPanel)); JPanel buttons = new JPanel(new BorderLayout()); @@ -130,6 +133,7 @@ public final class SettingsDialog extends JDialog { notificationsPanel.apply(); designPanel.apply(); clientVersionPanel.apply(); + contactsPanel.apply(); hotkeysPanel.apply(); settings.save(); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java b/ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java index 8b7dfcf..86a482b 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java @@ -128,6 +128,16 @@ public final class Theme { return pick(0xC0392B, 0xE05C4C); } + /** TeamSpeak's {@code CustomColor::ClientFriend}, lifted for the dark scheme. */ + public static Color friend() { + return pick(0x008000, 0x5CC96A); + } + + /** TeamSpeak's {@code CustomColor::ClientBlocked}, lifted for the dark scheme. */ + public static Color blocked() { + return pick(0xFF0000, 0xF06A5E); + } + public static Color accent() { return pick(0x2C7BE5, 0x5C9BEE); }