Add Set Server/Channel Group context menus and dialog

Lets moderators assign or revoke server and channel groups for a
client directly from its context menu, with per-group checkboxes
gated by the client's actual TS3 permissions (i_group_needed_member_
add/remove_power vs. each group's n_member_addp/removep, including
TS3's -1 "unlimited" sentinel). A "Server Groups Dialog..." entry
shows every server group, graying out ones the client can't touch.

Group membership changes now update ClientEntry locally instead of
only logging them, so the checkboxes and dialog reflect a change
immediately instead of needing a reconnect. The server's default
channel group (e.g. "Guest") is hidden from the channel-group menu
since it's not something you assign directly.
This commit is contained in:
2026-08-18 15:07:38 +00:00
parent e5b607fc29
commit acf0221837
11 changed files with 415 additions and 7 deletions

View File

@@ -6,6 +6,7 @@ public final class ClientEntry {
public int channelId;
public String nickname;
public String uniqueId = "";
public int databaseId;
public int type; // 0 = normal voice client, 1 = server-query
public int talkPower;
@@ -39,4 +40,28 @@ public final class ClientEntry {
public boolean isQuery() {
return type == 1;
}
/** Adds a server group id, if not already present. */
public void addServerGroup(int groupId) {
for (int id : serverGroupIds) if (id == groupId) return;
int[] updated = java.util.Arrays.copyOf(serverGroupIds, serverGroupIds.length + 1);
updated[serverGroupIds.length] = groupId;
serverGroupIds = updated;
}
/** Removes a server group id, if present. */
public void removeServerGroup(int groupId) {
int index = -1;
for (int i = 0; i < serverGroupIds.length; i++) {
if (serverGroupIds[i] == groupId) {
index = i;
break;
}
}
if (index < 0) return;
int[] updated = new int[serverGroupIds.length - 1];
System.arraycopy(serverGroupIds, 0, updated, 0, index);
System.arraycopy(serverGroupIds, index + 1, updated, index, serverGroupIds.length - index - 1);
serverGroupIds = updated;
}
}

View File

@@ -43,6 +43,7 @@ final class ConnectionEventHandler implements TS3Listener {
c.away = e.isClientAway();
c.awayMessage = TeamspeakConnection.orEmpty(e.get("client_away_message"));
c.uniqueId = TeamspeakConnection.orEmpty(e.getUniqueClientIdentifier());
c.databaseId = e.getClientDatabaseId();
c.serverGroupIds = parseIntList(e.getClientServerGroups());
c.channelGroupId = e.getClientChannelGroupId();
c.self = (e.getClientId() == conn.getSelfClientId());
@@ -423,6 +424,8 @@ final class ConnectionEventHandler implements TS3Listener {
@Override
public void onServerGroupClientAdded(ServerGroupClientAddedEvent e) {
ClientEntry c = conn.getModel().getClient(e.getClientId());
if (c != null) c.addServerGroup(e.getServerGroupId());
boolean self = e.getClientId() == conn.getSelfClientId();
conn.sound(self
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER,
@@ -432,11 +435,14 @@ final class ConnectionEventHandler implements TS3Listener {
groupVars(e.getClientId(), e.getName()));
conn.log(clientLogName(e.getClientId()) + " was added to server group \"" + e.getName()
+ "\" by " + invokerName(e) + ".");
conn.ui.onModelChanged();
}
@Override
public void onServerGroupClientDeleted(ServerGroupClientDeletedEvent e) {
int clientId = safeInt(e, "clid");
ClientEntry c = conn.getModel().getClient(clientId);
if (c != null) c.removeServerGroup(e.getServerGroupId());
boolean self = clientId == conn.getSelfClientId();
conn.sound(self
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER,
@@ -446,6 +452,7 @@ final class ConnectionEventHandler implements TS3Listener {
groupVars(clientId, e.get("name")));
conn.log(clientLogName(clientId) + " was removed from server group \"" + TeamspeakConnection.orEmpty(e.get("name"))
+ "\" by " + invokerName(e) + ".");
conn.ui.onModelChanged();
}
@Override
@@ -509,7 +516,18 @@ final class ConnectionEventHandler implements TS3Listener {
}
private static Group toGroup(BaseEvent e, int id) {
return new Group(id, e.get("name"), TeamspeakConnection.safeLong(e, "iconid"), safeInt(e, "sortid"));
return new Group(id, e.get("name"), TeamspeakConnection.safeLong(e, "iconid"), safeInt(e, "sortid"),
safeInt(e, "type"), safeInt(e, "n_member_addp"), safeInt(e, "n_member_removep"));
}
@Override
public void onPermissionList(PermissionListEvent e) {
conn.getModel().putPermissionName(e.get("permname"));
}
@Override
public void onClientNeededPermissions(ClientNeededPermissionsEvent e) {
conn.getModel().putSelfPermissionValue(safeInt(e, "permid"), safeInt(e, "permvalue"));
}
@Override

View File

@@ -9,11 +9,26 @@ public final class Group {
public final long iconId;
/** Display order among groups; lower comes first. */
public final int sortId;
/** TS3 group type: 0 = template, 1 = regular, 2 = query (server groups only). */
public final int type;
/** Power needed to add a member to this group. */
public final int neededMemberAddPower;
/** Power needed to remove a member from this group. */
public final int neededMemberRemovePower;
public Group(int id, String name, long iconId, int sortId) {
public Group(int id, String name, long iconId, int sortId, int type,
int neededMemberAddPower, int neededMemberRemovePower) {
this.id = id;
this.name = name;
this.iconId = iconId;
this.sortId = sortId;
this.type = type;
this.neededMemberAddPower = neededMemberAddPower;
this.neededMemberRemovePower = neededMemberRemovePower;
}
/** Regular, user-assignable groups exclude templates and query-only groups. */
public boolean isRegular() {
return type == 1;
}
}

View File

@@ -18,13 +18,29 @@ public final class ServerModel {
private final Map<Integer, ClientEntry> clients = new LinkedHashMap<>();
private final Map<Integer, Group> serverGroups = new LinkedHashMap<>();
private final Map<Integer, Group> channelGroups = new LinkedHashMap<>();
/**
* Permission id -> name, learned from the server's {@code permissionlist} response.
* Entries carry no id of their own: TS3 numbers them by position in the response,
* and the empty "group_id_end" separator records marking category boundaries don't
* count toward that position, so only {@link #putPermissionName} advances it.
*/
private final Map<Integer, String> permissionNames = new LinkedHashMap<>();
private int nextPermissionId;
/** Permission id -> the local client's resolved value, from {@code notifyclientneededpermissions}. */
private final Map<Integer, Integer> selfPermissionValues = new LinkedHashMap<>();
private String serverName = "TeamSpeak Server";
/** The channel group everyone starts in, e.g. "Guest" — not worth offering to (re)assign. */
private int defaultChannelGroupId;
public synchronized void clear() {
channels.clear();
clients.clear();
serverGroups.clear();
channelGroups.clear();
permissionNames.clear();
nextPermissionId = 0;
selfPermissionValues.clear();
defaultChannelGroupId = 0;
}
// ---- groups ----
@@ -78,6 +94,54 @@ public final class ServerModel {
return g == null ? null : g.name;
}
/** Regular (non-template, non-query) server groups, ordered for display. */
public synchronized List<Group> allServerGroups() {
return regularGroups(serverGroups);
}
/** Regular (non-template, non-query) channel groups, ordered for display. */
public synchronized List<Group> allChannelGroups() {
return regularGroups(channelGroups);
}
private static List<Group> regularGroups(Map<Integer, Group> groups) {
List<Group> list = new ArrayList<>();
for (Group g : groups.values()) {
if (g.isRegular()) list.add(g);
}
list.sort(Comparator.comparingInt((Group g) -> g.sortId).thenComparingInt(g -> g.id));
return list;
}
// ---- permissions ----
/** Records the next named entry of a {@code permissionlist} response; ignores separators. */
public synchronized void putPermissionName(String name) {
if (name == null || name.isEmpty()) return;
permissionNames.put(nextPermissionId, name);
nextPermissionId++;
}
public synchronized void putSelfPermissionValue(int permId, int value) {
selfPermissionValues.put(permId, value);
}
/**
* The local client's resolved value for a named permission (e.g.
* {@code "i_group_needed_member_add_power"}), or {@code 0} if it isn't known
* yet (the {@code permissionlist} request is still in flight, or the server
* never reported a non-default value for it).
*/
public synchronized int selfPermissionValue(String name) {
for (Map.Entry<Integer, String> entry : permissionNames.entrySet()) {
if (entry.getValue().equals(name)) {
Integer value = selfPermissionValues.get(entry.getKey());
if (value != null) return value;
}
}
return 0;
}
public synchronized String getServerName() {
return serverName;
}
@@ -86,6 +150,14 @@ public final class ServerModel {
if (name != null && !name.isEmpty()) this.serverName = name;
}
public synchronized int defaultChannelGroupId() {
return defaultChannelGroupId;
}
public synchronized void setDefaultChannelGroupId(int id) {
this.defaultChannelGroupId = id;
}
// ---- channels ----
public synchronized ChannelNode putChannel(int id, String name, int parentId, int order) {

View File

@@ -264,6 +264,7 @@ public final class TeamspeakConnection implements TS3Listener {
disconnectAnnounced = false;
deafened = false;
ui.onConnected();
requestPermissionNames();
ui.onStatus("Retrieving channels…");
syncAll();
@@ -396,6 +397,8 @@ public final class TeamspeakConnection implements TS3Listener {
}
// The name comes with initserver; servergetvariables never reports it.
model.setServerName(client.getServerName());
model.setDefaultChannelGroupId(
(int) safeLong(client.getServerProperties().get("virtualserver_default_channel_group")));
try {
for (Channel ch : client.listChannels()) {
model.putChannel(ch.getId(), ch.getName(), ch.getParentChannelId(), ch.getOrder());
@@ -647,6 +650,51 @@ public final class TeamspeakConnection implements TS3Listener {
}, "ts3j-move-channel").start();
}
/**
* Asks the server to (re-)send its permission definitions, which arrive as a burst
* of {@code notifypermissionlist} events — the only way to learn permission names,
* since {@code notifyclientneededpermissions} (which resolves our own power for
* them) only ever reports numeric ids. Needed for {@link ServerModel#selfPermissionValue}
* to work; harmless if it's slow or fails, since group-assignment eligibility just
* won't be known yet.
*/
private void requestPermissionNames() {
new Thread(() -> {
try {
client.executeCommand(new SingleCommand("permissionlist", ProtocolRole.CLIENT)).complete();
} catch (Exception ignored) {
// Best-effort: the menus fall back to treating unresolved permissions as 0.
}
}, "ts3j-permission-list").start();
}
/** Assigns or removes a server group for a client (by database id, as {@code servergroupaddclient} needs). */
public void setClientServerGroup(int clientDatabaseId, int groupId, boolean assign) {
new Thread(() -> {
try {
if (assign) client.serverGroupAddClient(groupId, clientDatabaseId);
else client.serverGroupRemoveClient(groupId, clientDatabaseId);
} catch (Exception e) {
error("Could not " + (assign ? "assign" : "remove") + " server group: " + rootMessage(e));
}
}, "ts3j-server-group").start();
}
/** Assigns a channel group for a client in the channel it currently sits in. */
public void setClientChannelGroup(int clientDatabaseId, int channelId, int groupId) {
new Thread(() -> {
try {
SingleCommand cmd = new SingleCommand("setclientchannelgroup", ProtocolRole.CLIENT);
cmd.add(new CommandSingleParameter("cgid", Integer.toString(groupId)));
cmd.add(new CommandSingleParameter("cid", Integer.toString(channelId)));
cmd.add(new CommandSingleParameter("cldbid", Integer.toString(clientDatabaseId)));
client.executeCommand(cmd).complete();
} catch (Exception e) {
error("Could not set channel group: " + rootMessage(e));
}
}, "ts3j-channel-group").start();
}
/**
* Subscribes to (or unsubscribes from) a set of channels in one command. The model
* is left alone: the server answers with the subscription events that update it.

View File

@@ -1,9 +1,14 @@
package com.ts3client.ui;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.Group;
import com.ts3client.net.ServerModel;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JMenu;
import javax.swing.JMenuItem;
import javax.swing.JPopupMenu;
import java.util.List;
/**
* The context menu for a client, shared by the server tree and the clickable
@@ -14,7 +19,8 @@ final class ClientMenu {
private ClientMenu() {
}
static JPopupMenu build(ClientEntry client, boolean self, ServerTreePanel.Actions actions) {
static JPopupMenu build(ClientEntry client, boolean self, ServerModel model, GroupIcons groupIcons,
ServerTreePanel.Actions actions) {
JPopupMenu menu = new JPopupMenu();
if (!self) {
JMenuItem pm = new JMenuItem("Open text chat", Icons.of("PLAYER_CHAT"));
@@ -44,6 +50,9 @@ final class ClientMenu {
menu.add(me);
}
menu.addSeparator();
menu.add(buildServerGroupMenu(client, model, groupIcons, actions));
menu.add(buildChannelGroupMenu(client, model, groupIcons, actions));
menu.addSeparator();
JMenuItem findInTree = new JMenuItem("Find Client in Channel Tree", Icons.of("PLAYER_ON"));
findInTree.addActionListener(a -> actions.findClientInTree(client));
menu.add(findInTree);
@@ -58,4 +67,68 @@ final class ClientMenu {
}
return menu;
}
private static JMenu buildServerGroupMenu(ClientEntry client, ServerModel model, GroupIcons groupIcons,
ServerTreePanel.Actions actions) {
JMenu menu = new JMenu("Set Server Groups");
menu.setIcon(Icons.of("PERMISSIONS_SERVER_GROUPS"));
JMenuItem dialog = new JMenuItem("Server Groups Dialog...");
dialog.addActionListener(a -> actions.showServerGroupsDialog(client));
menu.add(dialog);
menu.addSeparator();
int addPower = model.selfPermissionValue("i_group_needed_member_add_power");
int removePower = model.selfPermissionValue("i_group_needed_member_remove_power");
for (Group g : model.allServerGroups()) {
boolean assigned = contains(client.serverGroupIds, g.id);
if (!canAssign(g, assigned, addPower, removePower)) continue;
JCheckBoxMenuItem item = new JCheckBoxMenuItem(g.name, groupIcons.iconOf(g));
item.setSelected(assigned);
item.addActionListener(a -> actions.setClientServerGroup(client, g, !assigned));
menu.add(item);
}
return menu;
}
private static JMenu buildChannelGroupMenu(ClientEntry client, ServerModel model, GroupIcons groupIcons,
ServerTreePanel.Actions actions) {
JMenu menu = new JMenu("Set Channel Group");
menu.setIcon(Icons.of("PERMISSIONS_CHANNEL_GROUPS"));
int addPower = model.selfPermissionValue("i_group_needed_member_add_power");
int removePower = model.selfPermissionValue("i_group_needed_member_remove_power");
int defaultGroupId = model.defaultChannelGroupId();
for (Group g : model.allChannelGroups()) {
if (g.id == defaultGroupId) continue;
boolean assigned = client.channelGroupId == g.id;
if (!canAssign(g, assigned, addPower, removePower)) continue;
JCheckBoxMenuItem item = new JCheckBoxMenuItem(g.name, groupIcons.iconOf(g));
item.setSelected(assigned);
item.addActionListener(a -> actions.setClientChannelGroup(client, g));
menu.add(item);
}
return menu;
}
/**
* Whether the local client has enough power to (un)assign this group: the add
* power always gates the checkbox, and the remove power additionally gates
* unassigning an already-held group. {@code -1} is TS3's "unlimited" sentinel on
* either side: an unlimited local power always passes, and a group that needs
* unlimited power can only be touched by a local client that has it.
*/
static boolean canAssign(Group g, boolean assigned, int addPower, int removePower) {
return hasPower(g.neededMemberAddPower, addPower)
&& (!assigned || hasPower(g.neededMemberRemovePower, removePower));
}
private static boolean hasPower(int needed, int own) {
if (own == -1) return true;
if (needed == -1) return false;
return own >= needed;
}
private static boolean contains(int[] ids, int id) {
for (int i : ids) if (i == id) return true;
return false;
}
}

View File

@@ -0,0 +1,117 @@
package com.ts3client.ui;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.Group;
import com.ts3client.net.TeamspeakConnection;
import javax.swing.BorderFactory;
import javax.swing.BoxLayout;
import javax.swing.GrayFilter;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.SwingConstants;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Every regular server group on the server, as a scrollable checkbox list. Groups
* the local client has no power to (un)assign are shown too, so the client's full
* membership is visible, but greyed out and disabled.
*/
final class ServerGroupsDialog extends JDialog {
private final TeamspeakConnection conn;
private final GroupIcons groupIcons;
private final int clientId;
private final ServerTreePanel.Actions actions;
private final JPanel list = new JPanel();
private final JLabel header = new JLabel();
/** Cached grayscale icons, keyed by the source icon's identity. */
private final Map<ImageIcon, ImageIcon> grayscale = new HashMap<>();
ServerGroupsDialog(MainFrame owner, TeamspeakConnection conn, GroupIcons groupIcons,
ClientEntry client, ServerTreePanel.Actions actions) {
super(owner, "Server Groups", true);
this.conn = conn;
this.groupIcons = groupIcons;
this.clientId = client.id;
this.actions = actions;
list.setLayout(new BoxLayout(list, BoxLayout.Y_AXIS));
JScrollPane scroll = new JScrollPane(list);
scroll.setPreferredSize(new Dimension(280, 360));
scroll.getVerticalScrollBar().setUnitIncrement(16);
header.setBorder(BorderFactory.createEmptyBorder(8, 10, 4, 10));
header.setHorizontalAlignment(SwingConstants.LEFT);
JButton close = new JButton("Close");
close.addActionListener(a -> dispose());
JPanel buttons = new JPanel();
buttons.add(close);
getRootPane().setDefaultButton(close);
getContentPane().setLayout(new BorderLayout());
getContentPane().add(header, BorderLayout.NORTH);
getContentPane().add(scroll, BorderLayout.CENTER);
getContentPane().add(buttons, BorderLayout.SOUTH);
Dialogs.closeOnEscape(this);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
refresh();
pack();
setLocationRelativeTo(owner);
}
/** Rebuilds the list from the current model state; closes the dialog if the client left. */
void refresh() {
ClientEntry client = conn.getModel().getClient(clientId);
if (client == null) {
dispose();
return;
}
header.setText(client.nickname);
int addPower = conn.getModel().selfPermissionValue("i_group_needed_member_add_power");
int removePower = conn.getModel().selfPermissionValue("i_group_needed_member_remove_power");
List<Group> groups = conn.getModel().allServerGroups();
list.removeAll();
for (Group g : groups) {
boolean assigned = contains(client.serverGroupIds, g.id);
boolean allowed = ClientMenu.canAssign(g, assigned, addPower, removePower);
ImageIcon icon = groupIcons.iconOf(g);
JCheckBox box = new JCheckBox(g.name, allowed ? icon : grayscale(icon));
box.setSelected(assigned);
box.setEnabled(allowed);
box.setAlignmentX(0f);
if (allowed) {
box.addActionListener(a -> actions.setClientServerGroup(client, g, box.isSelected()));
}
list.add(box);
}
list.revalidate();
list.repaint();
}
private ImageIcon grayscale(ImageIcon icon) {
if (icon == null) return null;
return grayscale.computeIfAbsent(icon,
i -> new ImageIcon(GrayFilter.createDisabledImage(i.getImage())));
}
private static boolean contains(int[] ids, int id) {
for (int i : ids) if (i == id) return true;
return false;
}
}

View File

@@ -69,7 +69,7 @@ final class ServerTab implements ServerTabConnectionEvents.Listener {
this.groupIcons = new GroupIcons(conn.getIcons());
this.selfState = new ServerTabSelfState(conn);
this.chatPanel = new ChatPanel();
this.treeActions = new ServerTabTreeActions(host, this, conn, chatPanel, infoPanel);
this.treeActions = new ServerTabTreeActions(host, this, conn, chatPanel, infoPanel, groupIcons);
this.treePanel = new ServerTreePanel(conn.getModel(), groupIcons, treeActions);
treeActions.attach(treePanel);
events.attach(conn, treePanel, chatPanel, selfState, treeActions);

View File

@@ -90,6 +90,7 @@ final class ServerTabConnectionEvents implements ConnectionListener {
SwingUtilities.invokeLater(() -> {
treePanel.rebuild();
treeActions.renderInfo();
treeActions.refreshGroupsDialog();
String name = conn.getModel().getServerName();
if (conn.isConnected() && name != null && !name.isBlank() && !name.equals(tab.title())) {
listener.setTitle(name);

View File

@@ -2,6 +2,7 @@ package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.Group;
import com.ts3client.net.TeamspeakConnection;
import com.ts3client.text.TsLink;
@@ -24,23 +25,32 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions {
private final TeamspeakConnection conn;
private final ChatPanel chatPanel;
private final InfoPanel infoPanel;
private final GroupIcons groupIcons;
private ServerTreePanel treePanel;
private Object currentSelection;
/** The one open {@link ServerGroupsDialog}, if any, refreshed whenever the model changes. */
private ServerGroupsDialog groupsDialog;
ServerTabTreeActions(MainFrame host, ServerTab tab, TeamspeakConnection conn,
ChatPanel chatPanel, InfoPanel infoPanel) {
ChatPanel chatPanel, InfoPanel infoPanel, GroupIcons groupIcons) {
this.host = host;
this.tab = tab;
this.conn = conn;
this.chatPanel = chatPanel;
this.infoPanel = infoPanel;
this.groupIcons = groupIcons;
}
void attach(ServerTreePanel treePanel) {
this.treePanel = treePanel;
}
/** Called by {@link ServerTabConnectionEvents} whenever the model changes, to live-refresh an open dialog. */
void refreshGroupsDialog() {
if (groupsDialog != null) groupsDialog.refresh();
}
/** Refreshes the info panel for whatever is currently selected (or clears it). */
void renderInfo() {
Object sel = currentSelection;
@@ -74,7 +84,8 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions {
chatPanel.appendSystem("That client is no longer on the server.");
return;
}
ClientMenu.build(client, client.id == conn.getSelfClientId(), this).show(source, x, y);
ClientMenu.build(client, client.id == conn.getSelfClientId(), conn.getModel(), groupIcons, this)
.show(source, x, y);
}
void handleChannelLink(TsLink.Ref ref, Component source, int x, int y) {
@@ -171,6 +182,24 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions {
if (self != null) conn.moveClient(client.id, self.channelId, null);
}
@Override
public void setClientServerGroup(ClientEntry client, Group group, boolean assign) {
if (conn.isConnected()) conn.setClientServerGroup(client.databaseId, group.id, assign);
}
@Override
public void setClientChannelGroup(ClientEntry client, Group group) {
if (conn.isConnected()) conn.setClientChannelGroup(client.databaseId, client.channelId, group.id);
}
@Override
public void showServerGroupsDialog(ClientEntry client) {
if (!conn.isConnected()) return;
groupsDialog = new ServerGroupsDialog(host, conn, groupIcons, client, this);
groupsDialog.setVisible(true);
groupsDialog = null;
}
@Override
public void setChannelSubscribed(ChannelNode channel, boolean family, boolean subscribed) {
if (conn.isConnected()) conn.setChannelsSubscribed(channel.familyIds(family), subscribed);

View File

@@ -2,6 +2,7 @@ package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.Group;
import com.ts3client.net.ServerModel;
import javax.swing.DropMode;
@@ -71,6 +72,15 @@ public final class ServerTreePanel extends JScrollPane {
* siblings, or 0 to place it first
*/
void moveChannel(ChannelNode channel, int newParentId, int orderPredecessorId);
/** Assigns or removes a server group for a client. */
void setClientServerGroup(ClientEntry client, Group group, boolean assign);
/** Assigns a channel group for a client in its current channel. */
void setClientChannelGroup(ClientEntry client, Group group);
/** Opens the full server-groups list dialog for a client. */
void showServerGroupsDialog(ClientEntry client);
}
private final DropIndicatorTree tree;
@@ -187,7 +197,7 @@ public final class ServerTreePanel extends JScrollPane {
}
private void showClientMenu(ClientEntry client, MouseEvent e) {
ClientMenu.build(client, client.id == selfClientId, actions).show(tree, e.getX(), e.getY());
ClientMenu.build(client, client.id == selfClientId, model, groupIcons, actions).show(tree, e.getX(), e.getY());
}
private void showChannelMenu(ChannelNode channel, MouseEvent e) {