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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user