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

@@ -23,7 +23,7 @@ import java.awt.Insets;
* The channel editor's "Advanced" tab: the phonetic name, the delete delay of a temporary
* channel, voice encryption, and the two client limits.
*/
final class ChannelAdvancedPanel extends JPanel implements ChannelEditDialog.Tab {
final class ChannelAdvancedPanel extends JPanel implements ChannelDialog.Tab {
/** TeamSpeak's ceiling for {@code channel_delete_delay}, in seconds. */
private static final int MAX_DELETE_DELAY = 604800;

View File

@@ -20,7 +20,7 @@ import java.awt.Insets;
* The channel editor's "Audio" tab: the codec preset, or the codec and quality picked by
* hand, with the bandwidth one talking client then costs.
*/
final class ChannelAudioPanel extends JPanel implements ChannelEditDialog.Tab {
final class ChannelAudioPanel extends JPanel implements ChannelDialog.Tab {
/** Codec names in TeamSpeak's own id order, which is what the combo box index is. */
private static final String[] CODEC_NAMES = {

View File

@@ -35,15 +35,20 @@ import java.util.Map;
/**
* TeamSpeak's channel editor: the channel's name, icon, password, topic and description
* above four tabs holding everything else.
* above four tabs holding everything else. The official client uses the same dialog to
* create a channel and to edit one, and so does this.
*
* <p>The channel's properties are not in the tree model the tree only carries what the
* server pushes for display so the dialog opens empty and fills itself from a
* {@code channelinfo}, with its own permissions arriving separately. Saving sends only what
* was actually changed, because a {@code channeledit} carrying one property the client may
* not modify is refused as a whole.
* <p>When editing, the channel's properties are not in the tree model the tree only
* carries what the server pushes for display so the dialog opens empty and fills itself
* from a {@code channelinfo}, with its own permissions arriving separately. Saving sends
* only what was actually changed, because a {@code channeledit} carrying one property the
* client may not modify is refused as a whole.
*
* <p>When creating, there is nothing to read: the dialog starts from the defaults its
* caller chose and sends a {@code channelcreate} carrying whatever deviates from a plain
* new channel, for the same reason.
*/
final class ChannelEditDialog extends JDialog {
final class ChannelDialog extends JDialog {
/** A tab that maps a part of the channel's settings onto its controls. */
interface Tab {
@@ -66,13 +71,19 @@ final class ChannelEditDialog extends JDialog {
+ " (r=right, c=center, l=left),</td></tr>"
+ "<tr><td style='white-space:nowrap'>\"*\" will repeat the text to fill the whole line.</td></tr>"
+ "<tr><td style='white-space:nowrap'>Change \"#\" to get a unique channel name.</td></tr>"
+ "<tr><td style='white-space:nowrap'>[lSpacer0] a left aligned text</td></tr>"
+ "<tr><td style='white-space:nowrap'>[cSpacer1] a centered text</td></tr>"
+ "<tr><td style='white-space:nowrap'>[rSpacer2] a right aligned text</td></tr>"
+ "<tr><td style='white-space:nowrap'>Use one of the three-character-blocks as text for a"
+ " special spacer: \"---\", \"...\", \"-.-\", \"___\", \"-..\"</td></tr>"
+ "</table></html>";
private final TeamspeakConnection conn;
private final GroupIcons groupIcons;
/** The channel being edited, or {@code null} when a new one is being created. */
private final ChannelNode channel;
/** Where a new channel goes; 0 is the top level. Unused when editing. */
private final int parentId;
private final JTextField name = new JTextField();
private final JPasswordField password = new JPasswordField();
@@ -94,12 +105,33 @@ final class ChannelEditDialog extends JDialog {
private long iconId;
private boolean passwordEdited;
ChannelEditDialog(Window owner, TeamspeakConnection conn, GroupIcons groupIcons, ChannelNode channel) {
super(owner, "Edit Channel: " + channel.name, ModalityType.APPLICATION_MODAL);
/** The editor for an existing channel. */
static ChannelDialog toEdit(Window owner, TeamspeakConnection conn, GroupIcons groupIcons,
ChannelNode channel) {
return new ChannelDialog(owner, conn, groupIcons, "Edit Channel: " + channel.name,
channel, channel.parentId, null);
}
/**
* The editor for a channel that does not exist yet.
*
* @param parentId the channel it goes below, 0 being the top level
* @param defaults what the dialog opens with, e.g. the channel type its caller prefers
*/
static ChannelDialog toCreate(Window owner, TeamspeakConnection conn, GroupIcons groupIcons,
String title, int parentId, ChannelSettings defaults) {
return new ChannelDialog(owner, conn, groupIcons, title, null, parentId, defaults);
}
private ChannelDialog(Window owner, TeamspeakConnection conn, GroupIcons groupIcons, String title,
ChannelNode channel, int parentId, ChannelSettings defaults) {
super(owner, title, ModalityType.APPLICATION_MODAL);
this.conn = conn;
this.groupIcons = groupIcons;
this.channel = channel;
this.standardPanel = new ChannelStandardPanel(conn.getModel().siblingsOf(channel.id));
this.parentId = parentId;
this.standardPanel = new ChannelStandardPanel(channel != null
? conn.getModel().siblingsOf(channel.id) : conn.getModel().childrenOf(parentId));
standardPanel.onTypeChanged(advancedPanel::setChannelType);
settingsTabs = List.of(standardPanel, audioPanel, advancedPanel);
@@ -122,7 +154,8 @@ final class ChannelEditDialog extends JDialog {
setMinimumSize(new Dimension(480, 520));
setLocationRelativeTo(owner);
load();
if (channel != null) load();
else prepareNew(defaults);
}
// ---- layout ----
@@ -261,6 +294,16 @@ final class ChannelEditDialog extends JDialog {
}));
}
/**
* Opens the dialog on a channel that does not exist yet. Its permissions start empty
* and editable they are applied to the channel once the server has created it.
*/
private void prepareNew(ChannelSettings defaults) {
permissionsPanel.read(Map.of());
apply(defaults);
name.selectAll();
}
private void apply(ChannelSettings settings) {
original = settings;
name.setText(settings.name);
@@ -351,6 +394,14 @@ final class ChannelEditDialog extends JDialog {
return;
}
if (channel == null) {
ok.setEnabled(false);
conn.createChannel(parentId, edited.creationParameters(), permissionsPanel.changed(),
(channelId, error) -> SwingUtilities.invokeLater(
() -> finish(error, "Could not create the channel: ")));
return;
}
Map<String, String> changes = edited.changesFrom(original);
Map<String, Integer> setPermissions = permissionsPanel.changed();
List<String> clearedPermissions = permissionsPanel.cleared();
@@ -361,14 +412,16 @@ final class ChannelEditDialog extends JDialog {
ok.setEnabled(false);
conn.applyChannelEdit(channel.id, changes, setPermissions, clearedPermissions,
error -> SwingUtilities.invokeLater(() -> {
if (error == null) {
dispose();
return;
}
ok.setEnabled(true);
JOptionPane.showMessageDialog(this, "Could not save the channel: " + error,
"Error", JOptionPane.ERROR_MESSAGE);
}));
error -> SwingUtilities.invokeLater(() -> finish(error, "Could not save the channel: ")));
}
/** Closes the dialog, or reports why the server would not have it and lets the user retry. */
private void finish(String error, String errorPrefix) {
if (error == null) {
dispose();
return;
}
ok.setEnabled(true);
JOptionPane.showMessageDialog(this, errorPrefix + error, "Error", JOptionPane.ERROR_MESSAGE);
}
}

View File

@@ -1,6 +1,7 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ChannelSettings;
import javax.swing.JMenuItem;
import javax.swing.JPopupMenu;
@@ -20,6 +21,14 @@ final class ChannelMenu {
join.addActionListener(a -> actions.joinChannel(channel.id));
menu.add(join);
menu.addSeparator();
// Both start out temporary: a channel made from another channel's menu is
// usually a throwaway one, and a permanent channel is a deliberate choice.
JMenuItem create = new JMenuItem("Create Channel", Icons.of("CHANNEL_CREATE"));
create.addActionListener(a -> actions.createChannel(null, ChannelSettings.Type.TEMPORARY));
menu.add(create);
JMenuItem createSub = new JMenuItem("Create Sub-Channel", Icons.of("CHANNEL_CREATE_SUB"));
createSub.addActionListener(a -> actions.createChannel(channel, ChannelSettings.Type.TEMPORARY));
menu.add(createSub);
JMenuItem edit = new JMenuItem("Edit Channel", Icons.of("CHANNEL_EDIT"));
edit.addActionListener(a -> actions.editChannel(channel));
menu.add(edit);

View File

@@ -29,7 +29,7 @@ import java.util.function.Consumer;
* The channel editor's "Standard" tab: how long the channel lives, whether it is the
* server's default one, where it sorts among its siblings, and its moderation setting.
*/
final class ChannelStandardPanel extends JPanel implements ChannelEditDialog.Tab {
final class ChannelStandardPanel extends JPanel implements ChannelDialog.Tab {
private final JRadioButton temporary = new JRadioButton("Temporary");
private final JRadioButton semiPermanent = new JRadioButton("Semi-Permanent");

View File

@@ -0,0 +1,28 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelSettings;
import javax.swing.JMenuItem;
import javax.swing.JPopupMenu;
/**
* The context menu for the server itself — the tree's root row.
*/
final class ServerMenu {
private ServerMenu() {
}
static JPopupMenu build(ServerTreePanel.Actions actions) {
JPopupMenu menu = new JPopupMenu();
JMenuItem create = new JMenuItem("Create Channel", Icons.of("CHANNEL_CREATE"));
create.addActionListener(a -> actions.createChannel(null, ChannelSettings.Type.PERMANENT));
menu.add(create);
JMenuItem spacer = new JMenuItem("Create Spacer",
Icons.ofAny("CHANNEL_CREATE_SPACER", "CHANNEL_CREATE"));
spacer.setToolTipText("Create a new spacer");
spacer.addActionListener(a -> actions.createSpacer());
menu.add(spacer);
return menu;
}
}

View File

@@ -1,6 +1,7 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ChannelSettings;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.Group;
import com.ts3client.net.TeamspeakConnection;
@@ -208,7 +209,33 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions {
@Override
public void editChannel(ChannelNode channel) {
if (!conn.isConnected()) return;
new ChannelEditDialog(host, conn, groupIcons, channel).setVisible(true);
ChannelDialog.toEdit(host, conn, groupIcons, channel).setVisible(true);
}
@Override
public void createChannel(ChannelNode parent, ChannelSettings.Type type) {
if (!conn.isConnected()) return;
ChannelSettings defaults = new ChannelSettings();
defaults.type = type;
ChannelDialog.toCreate(host, conn, groupIcons,
parent == null ? "Create Channel" : "Create Sub-Channel of " + parent.name,
parent == null ? 0 : parent.id, defaults).setVisible(true);
}
@Override
public void createSpacer() {
if (!conn.isConnected()) return;
ChannelSettings defaults = new ChannelSettings();
// Spacers are cosmetic root channels, so they outlive everyone and, being channels,
// need a name no other channel has — which is what the tag's number is for.
defaults.name = Spacers.freeName(conn.getModel().childrenOf(0));
ChannelDialog.toCreate(host, conn, groupIcons, "Create Spacer", 0, defaults)
.setVisible(true);
}
@Override
public boolean isConnected() {
return conn.isConnected();
}
@Override

View File

@@ -1,6 +1,7 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ChannelSettings;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.Group;
import com.ts3client.net.ServerModel;
@@ -47,6 +48,20 @@ public final class ServerTreePanel extends JScrollPane {
/** Opens the channel editor for a channel. */
void editChannel(ChannelNode channel);
/**
* Opens the channel creator.
*
* @param parent the channel the new one goes below, or {@code null} for a top-level one
* @param type the channel type to start with
*/
void createChannel(ChannelNode parent, ChannelSettings.Type type);
/** Opens the channel creator pre-filled as a spacer, which is always top-level. */
void createSpacer();
/** Whether the server is there to act on at all. */
boolean isConnected();
/** Open the file repository browser for a channel. */
void browseFiles(ChannelNode channel);
@@ -196,6 +211,9 @@ public final class ServerTreePanel extends JScrollPane {
showClientMenu((ClientEntry) obj, e);
} else if (obj instanceof ChannelNode) {
showChannelMenu((ChannelNode) obj, e);
} else if (path.getPathCount() == 1 && actions.isConnected()) {
// The root row is the server itself, whose user object is just its name.
ServerMenu.build(actions).show(tree, e.getX(), e.getY());
}
}

View File

@@ -1,5 +1,10 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -21,6 +26,9 @@ public final class Spacers {
}
}
/** The tag's identifying part, which is what keeps two spacers' names apart. */
private static final Pattern TAG = Pattern.compile("^\\[(?:\\*|[lcr])?spacer([^\\]]*)\\]");
private static final Pattern PATTERN =
Pattern.compile("^\\[(\\*|[lcr])?spacer[^\\]]*\\](.*)$");
@@ -41,6 +49,22 @@ public final class Spacers {
return parse(channelName) != null;
}
/**
* A spacer name no channel among {@code siblings} is using: {@code [spacerN]} with the
* lowest free N. TeamSpeak needs channel names to be unique, and the number in the tag
* is how spacers — which usually all read the same — get away with it.
*/
public static String freeName(List<ChannelNode> siblings) {
Set<String> taken = new HashSet<>();
for (ChannelNode sibling : siblings) {
Matcher m = TAG.matcher(sibling.name == null ? "" : sibling.name);
if (m.find()) taken.add(m.group(1));
}
for (int n = 0; ; n++) {
if (taken.add(Integer.toString(n))) return "[spacer" + n + "]";
}
}
/** Builds the visible label for a spacer at roughly the given character width. */
public static String render(Spacer s, int width) {
String caption = s.caption == null ? "" : s.caption;