From f409f500fd6fb60d23ade4958f93936a0348049f Mon Sep 17 00:00:00 2001 From: ericek111 Date: Fri, 18 Sep 2026 07:50:58 +0000 Subject: [PATCH] Add the contact list (friends, blocked, neutral) Mirrors the TeamSpeak 3 client's Contacts: clients can be filed as friend, blocked or neutral from their context menu or by dragging them into the Contacts window, given a custom and phonetic nickname, shown under either name or both, and per contact muted automatically, ignored in server/channel or private chat and pokes, have their away message hidden and whispers allowed or denied. Friends are green and blocked clients red in the tree (optional), new contacts start from per-type defaults ("Set Defaults"), and the global whisper policy lives in Options -> Contacts. "Change Volume..." adds a per-client dB gain that a contact entry remembers. Contacts are stored in ~/.ts3jclient/contacts.txt using the exact record format the official client keeps in the Contacts table of its settings.db (field names and codes recovered from the binary), so entries move between the two verbatim. A small read-only pure-Java SQLite reader imports the official client's list on first run and on demand, without a driver. Co-Authored-By: Claude Opus 5 --- ts3-client/README.md | 14 + .../java/com/ts3client/audio/VoiceOutput.java | 5 + .../java/com/ts3client/config/Settings.java | 44 ++ .../java/com/ts3client/contacts/Contact.java | 199 +++++++ .../ts3client/contacts/ContactDefaults.java | 99 ++++ .../com/ts3client/contacts/ContactStore.java | 235 ++++++++ .../com/ts3client/contacts/ContactType.java | 29 + .../ts3client/contacts/NicknameDisplay.java | 43 ++ .../com/ts3client/contacts/SqliteReader.java | 288 ++++++++++ .../contacts/TeamSpeakSettingsDb.java | 66 +++ .../ts3client/net/ConnectionEventHandler.java | 13 +- .../java/com/ts3client/net/ServerModel.java | 5 + .../ts3client/net/TeamspeakConnection.java | 81 ++- .../ts3client/contacts/ContactStoreTest.java | 99 ++++ .../com/ts3client/contacts/ContactTest.java | 83 +++ .../contacts/TeamSpeakSettingsDbTest.java | 61 ++ .../src/test/resources/contacts/settings.db | Bin 0 -> 37376 bytes .../audio/desktop/DesktopVoiceOutput.java | 16 +- .../java/com/ts3client/ui/ClientMenu.java | 30 + .../com/ts3client/ui/ClientVolumeDialog.java | 79 +++ .../ts3client/ui/ContactDefaultsDialog.java | 199 +++++++ .../java/com/ts3client/ui/ContactsDialog.java | 537 ++++++++++++++++++ .../java/com/ts3client/ui/ContactsPanel.java | 89 +++ .../main/java/com/ts3client/ui/InfoPanel.java | 17 +- .../main/java/com/ts3client/ui/MainFrame.java | 42 +- .../java/com/ts3client/ui/MainMenuBar.java | 5 + .../main/java/com/ts3client/ui/ServerTab.java | 16 +- .../ui/ServerTabConnectionEvents.java | 3 +- .../ts3client/ui/ServerTabTreeActions.java | 32 +- .../ts3client/ui/ServerTreeCellRenderer.java | 32 +- .../com/ts3client/ui/ServerTreePanel.java | 22 +- .../java/com/ts3client/ui/SettingsDialog.java | 4 + .../src/main/java/com/ts3client/ui/Theme.java | 10 + 33 files changed, 2474 insertions(+), 23 deletions(-) create mode 100644 ts3-client/core/src/main/java/com/ts3client/contacts/Contact.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/contacts/ContactDefaults.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/contacts/ContactStore.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/contacts/ContactType.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/contacts/NicknameDisplay.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/contacts/SqliteReader.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/contacts/TeamSpeakSettingsDb.java create mode 100644 ts3-client/core/src/test/java/com/ts3client/contacts/ContactStoreTest.java create mode 100644 ts3-client/core/src/test/java/com/ts3client/contacts/ContactTest.java create mode 100644 ts3-client/core/src/test/java/com/ts3client/contacts/TeamSpeakSettingsDbTest.java create mode 100644 ts3-client/core/src/test/resources/contacts/settings.db create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/ClientVolumeDialog.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/ContactDefaultsDialog.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/ContactsDialog.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/ContactsPanel.java 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 0000000000000000000000000000000000000000..14183ed36bd07dd5f1a866d571251e3f98a1bb24 GIT binary patch literal 37376 zcmeI5drTb19mnSf$J>~P4H#_9Vz3S7we#3}fJuGE1fvpS8*rkel6%0~oH_3BabUaZ zLw4M%4@FheN{!S=QTvCbsEX=8QnhN-Mv5C%R;4Oc|492s{72I&t?H_ZtMrxr(b>U+ zF-)1NO=ZTO)$#r9?e4MMd}f%t&+-^MK9bD&&go2cE|GI6%0mF?kmDePLi}Txf5;`D zuLRrV0&-=88baNlI9CuHkSC5R+Td8b>prwVz@`uVDme2nd;oue_uv-13%`c9;g|3W z_!-=QAH!?#1NbgngKxuC_$IsnUx(-5S@=9W1D}SAkb?}I1s^^RPs2$#4kIuGhoK*$ zK%pDjp%t2-0cxNU%Ak<E;pc;^B4jU>!W@0Uo5C2X5ox?&s07k4N`j z9$l?GI$L;j?BQ{snMb?BqpgX@{@pzGHS*ZIi%08D9xV+#_SExe-oeAE5{v1n8R8Na%oYH*5x?2xp=g2f{N|FoeV`ovh6<2_;1K)- zy~|gw-P;J4%0oqUEiG^Gpk#X5zp!vF#oxS~N-X9w;{K^sr#^*O-6?z`zpxHB!t!Cu z#|~uDxx`d%;S@i+To2uOBEzFc2A_1+ALl$i>d0fAHvYi&OCcz#Yixu|PmA`EJ7vwg zr)A{U55HM@@(Sw)8vk9h$n&1UaxHiT7`!}0;`=}PG74^+V7Z)zf>6b0C;YMZkUM;2 ztUo?EFg3+8~ z&b!3iV$SEQ!)MZ&tbc5Ak{|Jyzp#)v<1gIZoJ}qza{l`5nb}oG#rZLb?8<}U=Mxw2 zJc4fvzJ#%v%=z()^TFwkCYJn@nbhK(|5#=^c{=H5`*G=-4PD{VCuWih^L{p-N@dRT z1CGsP(ta*EweoyQgCRQR`|1ARyL6XJyIeeuqrH^&Qns>l%+D_Q+0mc@#`!RFmhZ3G z9iN`g^5@;ZAP(+MB`?foy8Xr8SS%VTUFQD=Li}dzaygpEd(D+3?=^sfUh~w&3+I;> za|_vX^O^KqYW8e$=FDk-dTKJ^x~n?P;moA$GWc$n!T(E_kq`dQy9`!!8Fy`$aqsOh z?nXUk4S&%+)@+k~BAEWmmV2r0l!{LJ(L^?rTKUZVD{HH|<&$>V)GZK<`Az)=+Ar=y zX@NCkb7lN5zyE7S5SS{vtZ#uY9Lwi(%UA39+>%Fvxn8HAH9yOzJv z{I^K{Wmt&+jrimB!yjYI-)R2jlK&Pg;KH=UnTihVx9iO3jK$z|1+C^wd7xg zb@~r0^dGkV&usqNB>!rx(|=f@|FHFcX7k@J`ESEI{f8C$4_p6dHvbyQe>>6nKcet| z#QHz8`PWMRHALtCh{FF7>;KH=Unlw35}p4e3jar}|1+Ea4#~fc==>j1_&;L(pV|ED zCI1~n=l_Vp|MA~{yz?tVo92gd@5|4B8hib>LGrIBI{!x${*PGyXZHBtDfu@Lo&O^W z|3|FXe>>CpKc?`1%=$mG`FBYE2bj+PF@^tQ*8iE!zf zU^@TD6#kD{|7SM;F3G=>>HHs4_&;X-pV|DoCI2p_^M6d?|CsfEX7lfn{JWXX|1pLC zW7hwf&EJ*$dzjAuF@^tQ*8iE!A4~o&)A>K9@PEwuKePE0$saSF|6>aO$E^P|n?IHO ziKp{_p2Gim*8iE!pGp4I)A>J7;r~4A|IFs^N&d{!`9DwL|2*se%;q1K{5?;KH=ACvr}p3eVy3jgOl^!~3D z-aw5|i{JxzAASVigKO{-d<(t-UxR1ibMP7X6I_6Ea2BTEV=w`uFvR~;a14GA1UjG< z_(P!riUH6+(4XNQbPN3!-9&GpH_;9BI(ikof?kHV(N**!dLDfReGxr_J_&EZ&*7*1 zw3~Pl{~VpQh(h;>LYhUvjwo(ZP)KaIDA7hyBD+Kh?-a#r5QWu?LU)Kl>O{e{qPR6d z!LjY4M7N0&sTL(%C5l%m3ab!>ZWV=;i-Na^;+FB2Y}K`9L4F0$P>>`)NSqhSuPAYY ze*n507=pjUZTJKH25xdbZ^8|D9bSc3;AOZ9FT(Ti9DE6`z^7mdGB5)PcnbP}!8q>^ zN8zyU)DPPJpw+$DK~bW8L1V^BgN&9087U4jTqJf!dPNDxMDe1cu!tx$EDG^N!AumF z29Nd%gF7rnM2TWiBCaUm9#On*QCOEKv{Mw)AqqYqirXH1E>;lSp|LhmqWeXO>=Pxt z*N%Tx?-Y$=$G;u_s?LFe-tnM}|K6NelVxI2$pQpI~=h^FjyoVe7_y7AO k|6Wgb{m)Ze|MTqiKePECl>GZV-St0DasAJG=&%3%3svs$_5c6? literal 0 HcmV?d00001 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); }