Add the Create Channel dialog

The official client creates and edits channels with one dialog, so
ChannelEditDialog becomes ChannelDialog with two entry points. Creating
starts from caller-chosen defaults instead of a channelinfo and sends a
channelcreate carrying only what deviates from a plain new channel —
TeamSpeak checks a create permission per property that is present.

The server row now has a context menu with "Create Channel" and "Create
Spacer" (pre-filled with the lowest free [spacerN], since channel names
must be unique), and a channel's menu offers "Create Channel" and
"Create Sub-Channel", both starting out temporary.

Verified against the test server: a temporary channel is created, and a
permanent one a guest may not create reports the server's refusal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 15:31:07 +00:00
parent d8a56566d4
commit f2885d33ad
13 changed files with 279 additions and 29 deletions

View File

@@ -58,6 +58,31 @@ final class ChannelAdmin {
return ChannelSettings.from(channelId, info.getMap());
}
/**
* Creates a channel below {@code parentId} (0 being the top level).
*
* @param properties the new channel's properties, as {@link ChannelSettings#creationParameters}
* @return the new channel's id
*/
int create(int parentId, Map<String, String> properties) throws Exception {
SingleCommand cmd = new SingleCommand("channelcreate", ProtocolRole.CLIENT);
cmd.add(new CommandSingleParameter("cpid", Integer.toString(parentId)));
for (Map.Entry<String, String> property : properties.entrySet()) {
cmd.add(new CommandSingleParameter(property.getKey(), property.getValue()));
}
for (SingleCommand answer : conn.socket().executeCommand(cmd).get()) {
int cid = parseInt(answer.toMap().get("cid"), -1);
if (cid > 0) return cid;
}
// Some servers acknowledge the command without naming the channel; the
// notifychannelcreated they also send does name it, so read it off the model
// once that event has been handled.
conn.awaitEventsProcessed();
ChannelNode created = conn.getModel().findChannelByName(parentId, properties.get("channel_name"));
if (created == null) throw new IllegalStateException("The server did not report the new channel");
return created.id;
}
/** Applies {@code changes} (property name to value) with a single {@code channeledit}. */
void edit(int channelId, Map<String, String> changes) throws Exception {
if (changes.isEmpty()) return;

View File

@@ -143,6 +143,32 @@ public final class ChannelSettings {
return changes;
}
/**
* The {@code channelcreate} parameters for a channel that does not exist yet.
*
* <p>Everything a fresh channel would get anyway is left out, because TeamSpeak checks
* a create permission per property that is present — a client allowed to create plain
* channels but not, say, ones with a topic must not send an empty topic. What the
* server cannot infer is always sent: the name, the channel type (whose absent flags
* would silently mean temporary), the codec, and the client limits when they are not
* unlimited.
*/
public Map<String, String> creationParameters() {
Map<String, String> params = changesFrom(new ChannelSettings());
params.put("channel_name", name);
params.put("channel_flag_permanent", flag(type == Type.PERMANENT));
params.put("channel_flag_semi_permanent", flag(type == Type.SEMI_PERMANENT));
params.put("channel_flag_temporary", flag(type == Type.TEMPORARY));
params.put("channel_codec", Integer.toString(codec));
params.put("channel_codec_quality", Integer.toString(codecQuality));
if (!maxClientsUnlimited) params.put("channel_maxclients", Integer.toString(maxClients));
if (!familyInherited && !familyUnlimited) {
params.put("channel_maxfamilyclients", Integer.toString(maxFamilyClients));
}
if (password == null || password.isEmpty()) params.remove("channel_password");
return params;
}
private static void putIfChanged(Map<String, String> into, String key, String value, String was) {
if (!value.equals(was)) into.put(key, value);
}

View File

@@ -218,17 +218,34 @@ public final class ServerModel {
public synchronized List<ChannelNode> siblingsOf(int channelId) {
ChannelNode channel = channels.get(channelId);
if (channel == null) return new ArrayList<>();
List<ChannelNode> siblings = new ArrayList<>();
for (ChannelNode c : channels.values()) {
if (c.parentId == channel.parentId) siblings.add(c);
}
// Ordered with the channel still in place: the order links form a chain, and
// pulling a link out of it first would strand everything below.
sortSiblings(siblings);
List<ChannelNode> siblings = childrenOf(channel.parentId);
siblings.remove(channel);
return siblings;
}
/**
* The channels directly below {@code parentId} (0 being the top level), in the order
* they appear in the tree — what a new channel can be sorted after.
*/
public synchronized List<ChannelNode> childrenOf(int parentId) {
List<ChannelNode> children = new ArrayList<>();
for (ChannelNode c : channels.values()) {
if (c.parentId == parentId) children.add(c);
}
sortSiblings(children);
return children;
}
/** The channel of that exact name directly below {@code parentId}, or {@code null}. */
public synchronized ChannelNode findChannelByName(int parentId, String name) {
for (ChannelNode c : channels.values()) {
if (c.parentId == parentId && c.name.equals(name)) return c;
}
return null;
}
/** The "/"-separated path of a channel from the root, e.g. {@code "Lobby/Games"}. */
public synchronized String channelPath(int id) {
ChannelNode c = channels.get(id);

View File

@@ -923,6 +923,29 @@ public final class TeamspeakConnection implements TS3Listener {
});
}
/**
* Creates a channel below {@code parentId} (0 being the top level) and gives it the
* channel permissions it was created with.
*
* @param callback given the new channel's id, or the failure message. A channel whose
* permissions could not be set still exists, and is reported as an error
* saying so.
*/
public void createChannel(int parentId, Map<String, String> properties,
Map<String, Integer> permissions,
BiConsumer<Integer, String> callback) {
run("ts3j-channel-create", callback, () -> {
int channelId = channels.create(parentId, properties);
try {
channels.writePermissions(channelId, permissions, List.of());
} catch (Exception e) {
throw new IllegalStateException("The channel was created, but its permissions"
+ " could not be set: " + rootMessage(e));
}
return channelId;
});
}
/** The ids of the icons uploaded to this virtual server. */
public void requestServerIcons(BiConsumer<List<Long>, String> callback) {
run("ts3j-icon-list", callback, channels::listIcons);