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 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 07:50:58 +00:00
parent 22e0304f25
commit f409f500fd
33 changed files with 2474 additions and 23 deletions

View File

@@ -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 - **Identity management** (Tools → Identities) — keep several identities, mark one
as the default, pick one per server or per bookmark, rename, raise an identity's as the default, pick one per server or per bookmark, rename, raise an identity's
security level, and import/export TeamSpeak-compatible `.ini` identity files. security level, and import/export TeamSpeak-compatible `.ini` identity files.
- **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 / - **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 Remove / Edit, own-ban filter and highlighting, choosable columns), with the
official Add dialog: IP and nickname patterns read as IPv4/IPv6 wildcards, fixed official Add dialog: IP and nickname patterns read as IPv4/IPv6 wildcards, fixed

View File

@@ -27,6 +27,11 @@ public interface VoiceOutput {
boolean isClientMuted(int clientId); 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 setOutputDevice(String device);
void removeClient(int clientId); void removeClient(int clientId);

View File

@@ -1,5 +1,6 @@
package com.ts3client.config; package com.ts3client.config;
import com.ts3client.contacts.ContactDefaults;
import com.ts3client.sound.NotificationSettings; import com.ts3client.sound.NotificationSettings;
import java.io.File; import java.io.File;
@@ -43,6 +44,26 @@ public final class Settings {
HYBRID 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 DIR = new File(System.getProperty("user.home"), ".ts3jclient");
private static final File FILE = new File(DIR, "settings.properties"); 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. */ /** Which actions make a sound, and which ones are important enough to survive muting. */
public final NotificationSettings notifications = new NotificationSettings(); public final NotificationSettings notifications = new NotificationSettings();
// ---- 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() { public static Settings load() {
Settings s = new Settings(); Settings s = new Settings();
try { try {
@@ -240,6 +269,9 @@ public final class Settings {
banHighlightOwn = parseB(props.getProperty("banHighlightOwn"), banHighlightOwn); banHighlightOwn = parseB(props.getProperty("banHighlightOwn"), banHighlightOwn);
banListColumns = props.getProperty("banListColumns", banListColumns); banListColumns = props.getProperty("banListColumns", banListColumns);
notifications.load(props); notifications.load(props);
contactDefaults.load(props);
colorContacts = parseB(props.getProperty("colorContacts"), colorContacts);
whisperPolicy = parseEnum(WhisperPolicy.class, props.getProperty("whisperPolicy"), whisperPolicy);
} }
private void writeToProps() { private void writeToProps() {
@@ -285,6 +317,18 @@ public final class Settings {
props.setProperty("banHighlightOwn", Boolean.toString(banHighlightOwn)); props.setProperty("banHighlightOwn", Boolean.toString(banHighlightOwn));
props.setProperty("banListColumns", banListColumns); props.setProperty("banListColumns", banListColumns);
notifications.store(props); notifications.store(props);
contactDefaults.store(props);
props.setProperty("colorContacts", Boolean.toString(colorContacts));
props.setProperty("whisperPolicy", whisperPolicy.name());
}
private static <E extends Enum<E>> E parseEnum(Class<E> 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) { private static InputMode parseMode(String v, InputMode def) {

View File

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

View File

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

View File

@@ -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.
*
* <p>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.
*
* <p>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<String, Contact> byUid = new LinkedHashMap<>();
/** Immutable snapshot for readers, replaced under the lock after every change. */
private volatile List<Contact> snapshot = List.of();
private final List<Listener> 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<Contact> 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<Contact> 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<Contact> 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<Contact> 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<Contact> 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<Contact> 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;
}
}

View File

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

View File

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

View File

@@ -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.
*
* <p>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<String> columns;
final List<Object[]> rows;
Table(List<String> columns, List<Object[]> 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<String> 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<String> columnNames(String sql) {
List<String> 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<String> splitTopLevel(String s) {
List<String> 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<Object[]> rowsOf(int rootPage) throws IOException {
List<Object[]> rows = new ArrayList<>();
walk(rootPage, rows, 0);
return rows;
}
private void walk(int pageNumber, List<Object[]> 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<Long> 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;
}
}

View File

@@ -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<File> 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<Contact> 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<Contact> readContacts(File db) throws java.io.IOException {
List<Contact> 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;
}
}

View File

@@ -1,6 +1,8 @@
package com.ts3client.net; package com.ts3client.net;
import com.github.manevolent.ts3j.event.*; 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.SoundEvent;
import com.ts3client.sound.SoundNotifier; import com.ts3client.sound.SoundNotifier;
@@ -48,6 +50,7 @@ final class ConnectionEventHandler implements TS3Listener {
c.channelGroupId = e.getClientChannelGroupId(); c.channelGroupId = e.getClientChannelGroupId();
c.self = (e.getClientId() == conn.getSelfClientId()); c.self = (e.getClientId() == conn.getSelfClientId());
if (e.getClientId() != conn.getSelfClientId()) { if (e.getClientId() != conn.getSelfClientId()) {
conn.applyContact(c);
announceClientEntered(e); announceClientEntered(e);
logClientEntered(e); logClientEntered(e);
} }
@@ -570,6 +573,11 @@ final class ConnectionEventHandler implements TS3Listener {
@Override @Override
public void onTextMessage(TextMessageEvent e) { public void onTextMessage(TextMessageEvent e) {
if (e.getInvokerId() == conn.getSelfClientId()) return; // don't echo our own 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; ConnectionListener.ChatScope scope;
SoundEvent notification; SoundEvent notification;
switch (e.getTargetMode()) { switch (e.getTargetMode()) {
@@ -593,8 +601,11 @@ final class ConnectionEventHandler implements TS3Listener {
@Override @Override
public void onClientPoke(ClientPokeEvent e) { 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.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 @Override

View File

@@ -306,6 +306,11 @@ public final class ServerModel {
clients.remove(id); clients.remove(id);
} }
/** A snapshot of every client on the server. */
public synchronized List<ClientEntry> allClients() {
return new ArrayList<>(clients.values());
}
public synchronized ClientEntry findClientByUniqueId(String uniqueId) { public synchronized ClientEntry findClientByUniqueId(String uniqueId) {
if (uniqueId == null || uniqueId.isEmpty()) return null; if (uniqueId == null || uniqueId.isEmpty()) return null;
for (ClientEntry c : clients.values()) { for (ClientEntry c : clients.values()) {

View File

@@ -17,6 +17,8 @@ import com.ts3client.audio.VoiceOutput;
import com.ts3client.chatlog.ChatLogScope; import com.ts3client.chatlog.ChatLogScope;
import com.ts3client.chatlog.ChatLogStore; import com.ts3client.chatlog.ChatLogStore;
import com.ts3client.config.Settings; 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.FileTransfer;
import com.ts3client.net.filetransfer.FileTransferManager; import com.ts3client.net.filetransfer.FileTransferManager;
import com.ts3client.net.ban.BanEntry; import com.ts3client.net.ban.BanEntry;
@@ -66,6 +68,8 @@ public final class TeamspeakConnection implements TS3Listener {
private final IconRepository icons; private final IconRepository icons;
/** Package-private: read directly by {@link ConnectionEventHandler}. */ /** Package-private: read directly by {@link ConnectionEventHandler}. */
final SoundNotifier sounds; 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. */ /** The {@link TS3Listener} side of this connection; see its class doc. */
private final ConnectionEventHandler events = new ConnectionEventHandler(this); private final ConnectionEventHandler events = new ConnectionEventHandler(this);
/** Builds {@code getconnectioninfo} snapshots; also package-private for {@link ConnectionEventHandler}. */ /** Builds {@code getconnectioninfo} snapshots; also package-private for {@link ConnectionEventHandler}. */
@@ -112,11 +116,12 @@ public final class TeamspeakConnection implements TS3Listener {
private volatile long lastMutedTalkNanos; private volatile long lastMutedTalkNanos;
public TeamspeakConnection(Settings settings, AudioBackend audio, ConnectionListener ui, public TeamspeakConnection(Settings settings, AudioBackend audio, ConnectionListener ui,
SoundNotifier sounds) { SoundNotifier sounds, ContactStore contacts) {
this.settings = settings; this.settings = settings;
this.audio = audio; this.audio = audio;
this.ui = ui; this.ui = ui;
this.sounds = sounds; this.sounds = sounds;
this.contacts = contacts;
this.icons = new IconRepository(this::fetchIcon, ui::onIconsUpdated); this.icons = new IconRepository(this::fetchIcon, ui::onIconsUpdated);
} }
@@ -266,7 +271,9 @@ public final class TeamspeakConnection implements TS3Listener {
client.addListener(this); client.addListener(this);
client.addListener(events); client.addListener(events);
client.setVoiceHandler(playback::handleVoice); client.setVoiceHandler(playback::handleVoice);
client.setWhisperHandler(playback::handleWhisper); client.setWhisperHandler(whisper -> {
if (isWhisperAllowed(whisper.getClientId())) playback.handleWhisper(whisper);
});
client.setExceptionHandler(t -> { client.setExceptionHandler(t -> {
// A late packet dispatched after close() rejects on the shut-down // A late packet dispatched after close() rejects on the shut-down
// command executor; harmless during teardown. // 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 * Our own unique identifier on this server, which is how the server names us as a
* ban's creator; empty while not connected. * 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() { public String getSelfUniqueId() {
ClientEntry self = model.getClient(selfClientId); ClientEntry self = model.getClient(selfClientId);
if (self != null && !self.uniqueId.isEmpty()) return self.uniqueId; if (self != null && !self.uniqueId.isEmpty()) return self.uniqueId;

View File

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

View File

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

View File

@@ -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<Contact> 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<Contact> contacts, String uid) {
for (Contact c : contacts) if (c.uniqueId.equals(uid)) return c;
assertNotNull(null, "no contact " + uid);
return null;
}
}

View File

@@ -110,6 +110,8 @@ public final class DesktopVoiceOutput implements VoiceOutput {
private final Map<Integer, ClientStream> streams = new ConcurrentHashMap<>(); private final Map<Integer, ClientStream> streams = new ConcurrentHashMap<>();
private final Set<Integer> mutedClients = ConcurrentHashMap.newKeySet(); private final Set<Integer> mutedClients = ConcurrentHashMap.newKeySet();
/** Per-client gain on top of the master volume; absent means 1.0. */
private final Map<Integer, Double> clientVolumes = new ConcurrentHashMap<>();
private volatile String outputDevice; private volatile String outputDevice;
private volatile double masterVolume = 1.0; private volatile double masterVolume = 1.0;
@@ -175,6 +177,15 @@ public final class DesktopVoiceOutput implements VoiceOutput {
return mutedClients.contains(clientId); 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) { public void setOutputDevice(String device) {
this.outputDevice = device; this.outputDevice = device;
} }
@@ -333,7 +344,7 @@ public final class DesktopVoiceOutput implements VoiceOutput {
try { try {
float[] pcm = stream.pcm; float[] pcm = stream.pcm;
int frames = stream.decoderFor(channels).decode(data, 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 // Match the decoded stream to the line: duplicate mono across a stereo
// line, fold a stereo (music) stream down onto a mono-only line. // 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). */ /** 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) { public void removeClient(int clientId) {
ClientStream s = streams.remove(clientId); ClientStream s = streams.remove(clientId);
if (s != null) s.close(); if (s != null) s.close();
mutedClients.remove(clientId);
clientVolumes.remove(clientId);
} }
public void shutdown() { public void shutdown() {

View File

@@ -1,5 +1,6 @@
package com.ts3client.ui; package com.ts3client.ui;
import com.ts3client.contacts.ContactType;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry; import com.ts3client.net.ClientEntry;
import com.ts3client.net.Group; import com.ts3client.net.Group;
@@ -50,6 +51,11 @@ final class ClientMenu {
Icons.of(muted ? "PLAYER_ON" : "INPUT_MUTED")); Icons.of(muted ? "PLAYER_ON" : "INPUT_MUTED"));
mute.addActionListener(a -> actions.toggleClientMute(client)); mute.addActionListener(a -> actions.toggleClientMute(client));
menu.add(mute); 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 { } else {
JMenuItem me = new JMenuItem("This is you", Icons.of("PLAYER_OFF")); JMenuItem me = new JMenuItem("This is you", Icons.of("PLAYER_OFF"));
me.setEnabled(false); me.setEnabled(false);
@@ -84,6 +90,30 @@ final class ClientMenu {
return menu; 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 * 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). * we're not subscribed to it (e.g. it needs more subscribe power than we have).

View File

@@ -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("<html><body style='width:300px'>Values greater than +6 dB will cause clipping "
+ "and distortion that will negatively affect your audio quality. It is advised to choose "
+ "lower values.</body></html>");
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);
}
}

View File

@@ -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<ContactType, Map<String, JCheckBox>> flags = new EnumMap<>(ContactType.class);
private final Map<ContactType, Map<NicknameDisplay, JRadioButton>> 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<ContactType, ButtonGroup> 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<String, JCheckBox> 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<String, JCheckBox> 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<NicknameDisplay, JRadioButton> radio : displays.get(t).entrySet()) {
if (radio.getValue().isSelected()) e.nicknameDisplay = radio.getKey();
}
}
settings.save();
}
}

View File

@@ -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.
*
* <p>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<Contact> 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<Contact> 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<Contact> 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("<html>" + esc(c.nickname) + " <span style='color:" + hex(sel ? getForeground() : Theme.chatSystem())
+ "'>&nbsp;" + esc(lastSeen(c)) + "</span></html>");
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
private static String hex(java.awt.Color c) {
return String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), c.getBlue());
}
}
}

View File

@@ -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<Settings.WhisperPolicy> 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("<html>Use individual settings in contacts, else allow: a client not on your "
+ "contact list may whisper to you.<br>Use individual settings in contacts, else deny: only "
+ "contacts marked \"Allow whisper\" may.<br>Nobody: individual contact settings are ignored.</html>");
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();
}
}

View File

@@ -1,5 +1,6 @@
package com.ts3client.ui; package com.ts3client.ui;
import com.ts3client.contacts.Contact;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry; import com.ts3client.net.ClientEntry;
import com.ts3client.net.Group; import com.ts3client.net.Group;
@@ -50,6 +51,7 @@ public final class InfoPanel extends JPanel {
private ClientEntry shownClient; private ClientEntry shownClient;
private ServerModel shownModel; private ServerModel shownModel;
private IconRepository shownIcons; private IconRepository shownIcons;
private Contact shownContact;
private boolean inChatTab; private boolean inChatTab;
public InfoPanel() { public InfoPanel() {
@@ -144,11 +146,12 @@ public final class InfoPanel extends JPanel {
render(); render();
} }
public void showClient(ClientEntry cl, ServerModel model, IconRepository icons) { public void showClient(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact) {
shownClient = cl; shownClient = cl;
shownChannel = null; shownChannel = null;
shownModel = model; shownModel = model;
shownIcons = icons; shownIcons = icons;
shownContact = contact;
render(); render();
} }
@@ -166,7 +169,7 @@ public final class InfoPanel extends JPanel {
private String body() { private String body() {
if (shownChannel != null) return channelBody(shownChannel); if (shownChannel != null) return channelBody(shownChannel);
if (shownClient != null) return clientBody(shownClient, shownModel, shownIcons); if (shownClient != null) return clientBody(shownClient, shownModel, shownIcons, shownContact);
return "<i class='muted'>Select a channel or client to see details.</i>"; return "<i class='muted'>Select a channel or client to see details.</i>";
} }
@@ -187,9 +190,15 @@ public final class InfoPanel extends JPanel {
return sb.toString(); 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(); StringBuilder sb = new StringBuilder();
sb.append(heading(esc(cl.nickname) + (cl.self ? " <span class='muted'>(you)</span>" : ""))); String name = contact == null ? cl.nickname : contact.displayName(cl.nickname);
sb.append(heading(esc(name) + (cl.self ? " <span class='muted'>(you)</span>" : "")));
if (contact != null) {
String type = contact.type.label();
if (!contact.phoneticNickname.isEmpty()) type += " (" + esc(contact.phoneticNickname) + ")";
row(sb, "Contact", type);
}
List<Group> serverGroups = model.serverGroupsOf(cl.serverGroupIds); List<Group> serverGroups = model.serverGroupsOf(cl.serverGroupIds);
if (serverGroups.isEmpty()) { if (serverGroups.isEmpty()) {

View File

@@ -8,6 +8,7 @@ import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks; import com.ts3client.config.Bookmarks;
import com.ts3client.config.IdentityStore; import com.ts3client.config.IdentityStore;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import com.ts3client.contacts.ContactStore;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.sound.SoundNotifier; import com.ts3client.sound.SoundNotifier;
import com.ts3client.sound.SoundPlayer; 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 Bookmarks bookmarks = Bookmarks.load();
private final AwayMessages awayMessages = AwayMessages.load(); private final AwayMessages awayMessages = AwayMessages.load();
private final IdentityStore identities; private final IdentityStore identities;
/** Friends and blocked clients, shared by every connection. */
private final ContactStore contacts = ContactStore.load();
private final AudioBackend audio = new DesktopAudioBackend(); private final AudioBackend audio = new DesktopAudioBackend();
/** Sound pack playback, shared by every connection. */ /** Sound pack playback, shared by every connection. */
private final SoundNotifier sounds; private final SoundNotifier sounds;
@@ -86,6 +89,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
this.soundPlayer = audio.createSoundPlayer(settings); this.soundPlayer = audio.createSoundPlayer(settings);
this.sounds.setPlayer(soundPlayer); this.sounds.setPlayer(soundPlayer);
this.hotkeys = new HotkeyService(new HotkeyActions(this), this::hotkeyArgumentChoices); this.hotkeys = new HotkeyService(new HotkeyActions(this), this::hotkeyArgumentChoices);
contacts.addListener(() -> SwingUtilities.invokeLater(this::contactsChanged));
setIconImage(Icons.app().getImage()); setIconImage(Icons.app().getImage());
// We tear the connections down ourselves on close, so don't let Swing kill the JVM. // 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(); showIdentities();
} }
@Override
public void onShowContacts() {
showContacts(null);
}
@Override @Override
public boolean canViewBanList() { public boolean canViewBanList() {
return selected != null && selected.canViewBanList(); return selected != null && selected.canViewBanList();
@@ -310,7 +319,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
// ---- tab management ---- // ---- tab management ----
private ServerTab newTab() { 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); tabs.add(tab);
tabPane.addTab(tab); tabPane.addTab(tab);
refreshTabs(); refreshTabs();
@@ -765,6 +774,32 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
new IdentitiesDialog(this, identities, settings, bookmarks, null).setVisible(true); 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() { private void showSettings() {
// Device and voice-gating changes apply to the capturing connection and the // 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. // 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(), micTab == null ? null : micTab.connection().getMicrophone(),
selected == null ? null : selected.connection().getPlayback(), selected == null ? null : selected.connection().getPlayback(),
sounds, hotkeys, sounds, hotkeys,
this::applyOutputSettingsToAllTabs); () -> {
applyOutputSettingsToAllTabs();
contactsChanged();
});
dlg.setVisible(true); dlg.setVisible(true);
} }

View File

@@ -48,6 +48,8 @@ final class MainMenuBar extends JMenuBar {
void onShowIdentities(); void onShowIdentities();
void onShowContacts();
/** Whether the current server lets us read its ban list; asked as the Tools menu opens. */ /** Whether the current server lets us read its ban list; asked as the Tools menu opens. */
boolean canViewBanList(); boolean canViewBanList();
@@ -123,12 +125,15 @@ final class MainMenuBar extends JMenuBar {
JMenu tools = new JMenu("Tools"); JMenu tools = new JMenu("Tools");
JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER")); JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER"));
identitiesItem.addActionListener(e -> listener.onShowIdentities()); identitiesItem.addActionListener(e -> listener.onShowIdentities());
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 = new JMenuItem("Ban List", Icons.of("BAN_LIST"));
banListItem.addActionListener(e -> listener.onShowBanList()); banListItem.addActionListener(e -> listener.onShowBanList());
JMenuItem options = new JMenuItem("Options…", Icons.of("SETTINGS")); JMenuItem options = new JMenuItem("Options…", Icons.of("SETTINGS"));
options.addActionListener(e -> listener.onShowSettings()); options.addActionListener(e -> listener.onShowSettings());
JPopupMenu.Separator banListSeparator = new JPopupMenu.Separator(); JPopupMenu.Separator banListSeparator = new JPopupMenu.Separator();
tools.add(identitiesItem); tools.add(identitiesItem);
tools.add(contactsItem);
tools.addSeparator(); tools.addSeparator();
tools.add(banListItem); tools.add(banListItem);
tools.add(banListSeparator); tools.add(banListSeparator);

View File

@@ -5,6 +5,7 @@ import com.ts3client.config.IdentityEntry;
import com.ts3client.config.IdentityStore; import com.ts3client.config.IdentityStore;
import com.ts3client.config.BanReasonPresets; import com.ts3client.config.BanReasonPresets;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import com.ts3client.contacts.ContactStore;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry; import com.ts3client.net.ClientEntry;
import com.ts3client.net.ServerModel; import com.ts3client.net.ServerModel;
@@ -64,7 +65,7 @@ final class ServerTab implements ServerTabConnectionEvents.Listener {
private BanListDialog banList; private BanListDialog banList;
ServerTab(MainFrame host, Settings settings, IdentityStore identities, AudioBackend audio, ServerTab(MainFrame host, Settings settings, IdentityStore identities, AudioBackend audio,
SoundNotifier sounds) { SoundNotifier sounds, ContactStore contacts) {
this.host = host; this.host = host;
this.settings = settings; this.settings = settings;
this.identities = identities; 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 // 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. // an explicit attach() once everything is built. Nothing fires callbacks before then.
this.events = new ServerTabConnectionEvents(host, this, this); 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.groupIcons = new GroupIcons(conn.getIcons());
this.selfState = new ServerTabSelfState(conn); this.selfState = new ServerTabSelfState(conn);
this.chatPanel = new ChatPanel(); this.chatPanel = new ChatPanel();
this.treeActions = new ServerTabTreeActions(host, this, conn, chatPanel, infoPanel, groupIcons); 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); treeActions.attach(treePanel);
events.attach(conn, treePanel, chatPanel, selfState, treeActions); events.attach(conn, treePanel, chatPanel, selfState, treeActions);
@@ -298,6 +299,13 @@ final class ServerTab implements ServerTabConnectionEvents.Listener {
else banList.connectionLost(); 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. */ /** Releases the background resources of a tab that is being thrown away. */
void dispose() { void dispose() {
conn.getIcons().shutdown(); conn.getIcons().shutdown();
@@ -388,7 +396,7 @@ final class ServerTab implements ServerTabConnectionEvents.Listener {
private String peerName(int clientId) { private String peerName(int clientId) {
ClientEntry c = conn.getModel().getClient(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. */ /** Joins the channel at a "/"-separated path, as the hotkey action names it. */

View File

@@ -132,7 +132,8 @@ final class ServerTabConnectionEvents implements ConnectionListener {
} }
@Override @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(() -> { SwingUtilities.invokeLater(() -> {
switch (scope) { switch (scope) {
case PRIVATE: case PRIVATE:

View File

@@ -1,5 +1,6 @@
package com.ts3client.ui; package com.ts3client.ui;
import com.ts3client.contacts.ContactType;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.net.ChannelSettings; import com.ts3client.net.ChannelSettings;
import com.ts3client.net.ClientEntry; import com.ts3client.net.ClientEntry;
@@ -59,7 +60,8 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions {
if (sel instanceof ChannelNode) { if (sel instanceof ChannelNode) {
infoPanel.showChannel((ChannelNode) sel); infoPanel.showChannel((ChannelNode) sel);
} else if (sel instanceof ClientEntry) { } 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 { } else {
infoPanel.clear(); infoPanel.clear();
} }
@@ -123,7 +125,7 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions {
@Override @Override
public void openPrivateChat(ClientEntry client) { 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); 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 @Override
public void renameSelf(String nickname) { public void renameSelf(String nickname) {
host.changeNickname(nickname); host.changeNickname(nickname);

View File

@@ -1,5 +1,8 @@
package com.ts3client.ui; 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.ChannelNode;
import com.ts3client.net.ClientEntry; import com.ts3client.net.ClientEntry;
@@ -7,6 +10,7 @@ import javax.swing.ImageIcon;
import javax.swing.JTree; import javax.swing.JTree;
import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.DefaultTreeCellRenderer; import javax.swing.tree.DefaultTreeCellRenderer;
import java.awt.Color;
import java.awt.Component; import java.awt.Component;
import java.util.function.IntPredicate; 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. */ /** Whether a client id is our own, which the panel learns on connecting. */
private final IntPredicate isSelf; 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.isSelf = isSelf;
this.contacts = contacts;
this.settings = settings;
} }
@Override @Override
@@ -49,15 +57,18 @@ final class ServerTreeCellRenderer extends DefaultTreeCellRenderer {
} }
} else if (obj instanceof ClientEntry) { } else if (obj instanceof ClientEntry) {
ClientEntry cl = (ClientEntry) obj; ClientEntry cl = (ClientEntry) obj;
String label = cl.nickname; Contact contact = contacts.find(cl.uniqueId);
if (cl.away && !cl.awayMessage.isEmpty()) label += " [" + cl.awayMessage + "]"; 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); setText(label);
setIcon(iconFor(cl)); setIcon(iconFor(cl));
// Talking shows in the client's icon, as it does in TeamSpeak. Marking the // 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 // 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 // nickname clipped to an ellipsis for as long as it lasts. Our own name is
// bold throughout, so its width never changes either. // bold throughout, so its width never changes either.
setForeground(Theme.treeText()); setForeground(contactColor(contact));
setFont(isSelf.test(cl.id) ? Theme.uiBold() : Theme.uiFont()); setFont(isSelf.test(cl.id) ? Theme.uiBold() : Theme.uiFont());
} else { } else {
// root / server // root / server
@@ -69,6 +80,19 @@ final class ServerTreeCellRenderer extends DefaultTreeCellRenderer {
return this; 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) { private ImageIcon iconFor(ChannelNode c) {
if (c.hasPassword) return Icons.channelLocked(c.subscribed); if (c.hasPassword) return Icons.channelLocked(c.subscribed);
if (c.maxClients >= 0 && c.clients.size() >= c.maxClients) return Icons.channelFull(c.subscribed); if (c.maxClients >= 0 && c.clients.size() >= c.maxClients) return Icons.channelFull(c.subscribed);

View File

@@ -1,5 +1,8 @@
package com.ts3client.ui; 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.ChannelNode;
import com.ts3client.net.ChannelSettings; import com.ts3client.net.ChannelSettings;
import com.ts3client.net.ClientEntry; 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. */ /** A new nickname for ourselves, typed into the tree row. */
void renameSelf(String nickname); 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; private final DropIndicatorTree tree;
@@ -134,7 +151,8 @@ public final class ServerTreePanel extends JScrollPane {
private final NicknameCellEditor nicknameEditor; private final NicknameCellEditor nicknameEditor;
private int selfClientId = -1; 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.model = model;
this.groupIcons = groupIcons; this.groupIcons = groupIcons;
this.actions = actions; this.actions = actions;
@@ -153,7 +171,7 @@ public final class ServerTreePanel extends JScrollPane {
tree.setRowHeight(20); tree.setRowHeight(20);
tree.setBackground(Theme.treeBg()); tree.setBackground(Theme.treeBg());
tree.setFont(Theme.uiFont()); tree.setFont(Theme.uiFont());
ServerTreeCellRenderer renderer = new ServerTreeCellRenderer(id -> id == selfClientId); ServerTreeCellRenderer renderer = new ServerTreeCellRenderer(id -> id == selfClientId, contacts, settings);
tree.setCellRenderer(renderer); tree.setCellRenderer(renderer);
nicknameEditor = NicknameCellEditor.create(tree, renderer, id -> id == selfClientId); nicknameEditor = NicknameCellEditor.create(tree, renderer, id -> id == selfClientId);
tree.setCellEditor(nicknameEditor); tree.setCellEditor(nicknameEditor);

View File

@@ -35,6 +35,7 @@ public final class SettingsDialog extends JDialog {
private final DesignPanel designPanel; private final DesignPanel designPanel;
private final HotkeysPanel hotkeysPanel; private final HotkeysPanel hotkeysPanel;
private final ClientVersionPanel clientVersionPanel; private final ClientVersionPanel clientVersionPanel;
private final ContactsPanel contactsPanel;
public SettingsDialog(Frame owner, Settings settings, public SettingsDialog(Frame owner, Settings settings,
VoiceInput liveMic, VoiceOutput livePlayback, VoiceInput liveMic, VoiceOutput livePlayback,
@@ -49,6 +50,7 @@ public final class SettingsDialog extends JDialog {
designPanel = new DesignPanel(settings); designPanel = new DesignPanel(settings);
hotkeysPanel = new HotkeysPanel(hotkeys); hotkeysPanel = new HotkeysPanel(hotkeys);
clientVersionPanel = new ClientVersionPanel(settings); clientVersionPanel = new ClientVersionPanel(settings);
contactsPanel = new ContactsPanel(settings);
devicesPanel = new DevicesPanel(settings, livePlayback, devicesPanel = new DevicesPanel(settings, livePlayback,
this::applyLive, this::restartTest, this::setTestOutputDevice); this::applyLive, this::restartTest, this::setTestOutputDevice);
voiceActivationPanel = new VoiceActivationPanel(settings, liveMic, hotkeys, voiceActivationPanel = new VoiceActivationPanel(settings, liveMic, hotkeys,
@@ -60,6 +62,7 @@ public final class SettingsDialog extends JDialog {
tabs.addTab("Notifications", notificationsPanel); tabs.addTab("Notifications", notificationsPanel);
tabs.addTab("Design", designPanel); tabs.addTab("Design", designPanel);
tabs.addTab("Hotkeys", hotkeysPanel); tabs.addTab("Hotkeys", hotkeysPanel);
tabs.addTab("Contacts", contactsPanel);
tabs.addTab("Client Version", scrollable(clientVersionPanel)); tabs.addTab("Client Version", scrollable(clientVersionPanel));
JPanel buttons = new JPanel(new BorderLayout()); JPanel buttons = new JPanel(new BorderLayout());
@@ -130,6 +133,7 @@ public final class SettingsDialog extends JDialog {
notificationsPanel.apply(); notificationsPanel.apply();
designPanel.apply(); designPanel.apply();
clientVersionPanel.apply(); clientVersionPanel.apply();
contactsPanel.apply();
hotkeysPanel.apply(); hotkeysPanel.apply();
settings.save(); settings.save();

View File

@@ -128,6 +128,16 @@ public final class Theme {
return pick(0xC0392B, 0xE05C4C); 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() { public static Color accent() {
return pick(0x2C7BE5, 0x5C9BEE); return pick(0x2C7BE5, 0x5C9BEE);
} }