Move clients to another channel or our own from the Android client sheet
"Move to channel…" closes the sheet and marks the client, and the next channel tapped is where they go; a bar above the tree names them and cancels. "Move to own channel" shows while they are elsewhere. Both show only with some i_client_move_power. The server's needed permissions never carry it, even for an admin, so core asks for it with permget on connect and whenever our own groups change; a refusal (guests may not ask) counts as none. Whether it beats the client's needed move power only the server knows, and it refuses when not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -142,6 +142,12 @@ class ServerController internal constructor(
|
||||
fun toggleCollapsed(channelId: Int) =
|
||||
_collapsed.update { if (channelId in it) it - channelId else it + channelId }
|
||||
|
||||
val canMoveClients: Boolean get() = session.canMoveClients()
|
||||
|
||||
fun moveClient(clientId: Int, channelId: Int) {
|
||||
if (session.isConnected) conn.moveClient(clientId, channelId, null)
|
||||
}
|
||||
|
||||
fun poke(clientId: Int, message: String) {
|
||||
if (session.isConnected) conn.poke(clientId, message)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -114,6 +115,8 @@ fun ServerScreen(
|
||||
var clientSheet by remember { mutableStateOf<Int?>(null) }
|
||||
var channelSheet by remember { mutableStateOf<Int?>(null) }
|
||||
var serverSheet by remember { mutableStateOf(false) }
|
||||
/** The client to move, while a channel for them is being picked. */
|
||||
var moving by remember { mutableStateOf<Int?>(null) }
|
||||
val infoUpdates by server.infoUpdates.collectAsStateWithLifecycle()
|
||||
|
||||
val chatRequest by server.chatRequest.collectAsStateWithLifecycle()
|
||||
@@ -141,6 +144,7 @@ fun ServerScreen(
|
||||
}
|
||||
|
||||
BackHandler(showChat) { showChat = false }
|
||||
BackHandler(moving != null && !showChat) { moving = null }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -170,13 +174,28 @@ fun ServerScreen(
|
||||
) { padding ->
|
||||
Column(Modifier.padding(padding).consumeWindowInsets(padding).imePadding()) {
|
||||
if (!showChat) {
|
||||
moving?.let { id ->
|
||||
val client = tree.client(id)
|
||||
if (client == null) {
|
||||
LaunchedEffect(id) { moving = null }
|
||||
} else {
|
||||
MovePrompt(client.name) { moving = null }
|
||||
}
|
||||
}
|
||||
ChannelTree(
|
||||
serverName = state.title,
|
||||
tree = tree,
|
||||
collapsed = collapsed,
|
||||
currentChannel = state.channelId,
|
||||
onToggle = { server.toggleCollapsed(it.id) },
|
||||
onChannel = { if (it.spacer == null) channelSheet = it.id },
|
||||
onChannel = { channel ->
|
||||
val client = moving
|
||||
when {
|
||||
channel.spacer != null -> {}
|
||||
client != null -> { server.moveClient(client, channel.id); moving = null }
|
||||
else -> channelSheet = channel.id
|
||||
}
|
||||
},
|
||||
onClient = { clientSheet = it.id },
|
||||
onServer = { serverSheet = true },
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -221,6 +240,10 @@ fun ServerScreen(
|
||||
onAddContact = { server.addContact(id, it) },
|
||||
onEditContact = { clientSheet = null; onContacts(client.uniqueId) },
|
||||
onRemoveContact = { vm.removeContact(client.uniqueId) },
|
||||
onMove = if (server.canMoveClients) ({ clientSheet = null; moving = id }) else null,
|
||||
onMoveToOwnChannel = if (server.canMoveClients && tree.channel(state.channelId)?.clients?.none { it.id == id } == true) {
|
||||
{ server.moveClient(id, state.channelId); clientSheet = null }
|
||||
} else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -245,6 +268,19 @@ fun ServerScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** Says a channel is being picked for [name] to move to, until it is or [onCancel]. */
|
||||
@Composable
|
||||
private fun MovePrompt(name: String, onCancel: () -> Unit) {
|
||||
Surface(color = MaterialTheme.colorScheme.secondaryContainer) {
|
||||
Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
PackIcon("CHANNEL_SWITCH", null)
|
||||
Text("Move $name to…", Modifier.weight(1f).padding(horizontal = 12.dp), maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
|
||||
TextButton(onClick = onCancel) { Text("Cancel") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute, deafen, away and the chat, around the push-to-talk button when that is how the
|
||||
* microphone is used. Holding the mute button switches between push to talk and voice activation.
|
||||
|
||||
@@ -37,7 +37,7 @@ import com.ts3client.contacts.ContactType
|
||||
import com.ts3client.net.Details
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** What can be done with a client: the private chat, a poke, their volume, filing them as a contact. */
|
||||
/** What can be done with a client: the private chat, a poke, a move, their volume, filing them as a contact. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ClientSheet(
|
||||
@@ -54,6 +54,10 @@ fun ClientSheet(
|
||||
onAddContact: (ContactType) -> Unit,
|
||||
onEditContact: () -> Unit,
|
||||
onRemoveContact: () -> Unit,
|
||||
/** Null when the client cannot be moved. */
|
||||
onMove: (() -> Unit)?,
|
||||
/** Null when the client cannot be moved, or is in our channel already. */
|
||||
onMoveToOwnChannel: (() -> Unit)?,
|
||||
) {
|
||||
var poking by remember { mutableStateOf(false) }
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
@@ -73,6 +77,8 @@ fun ClientSheet(
|
||||
} else {
|
||||
Action("PLAYER_CHAT", "Send message", onMessage)
|
||||
Action("POKE", "Poke") { poking = true }
|
||||
onMove?.let { Action("CHANNEL_SWITCH", "Move to channel…", it) }
|
||||
onMoveToOwnChannel?.let { Action("MOVE_CLIENT_TO_OWN_CHANNEL", "Move to own channel", it) }
|
||||
when (client.contactType) {
|
||||
null -> {
|
||||
Action("ADD_FRIEND", "Add as friend") { onAddContact(ContactType.FRIEND) }
|
||||
|
||||
@@ -506,6 +506,7 @@ final class ConnectionEventHandler implements TS3Listener {
|
||||
ClientEntry c = conn.getModel().getClient(e.getClientId());
|
||||
if (c != null) c.addServerGroup(e.getServerGroupId());
|
||||
boolean self = e.getClientId() == conn.getSelfClientId();
|
||||
if (self) conn.requestMovePower();
|
||||
conn.sound(self
|
||||
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER,
|
||||
SoundEvent.YOU_SERVERGROUP_ADDED_BY_SERVER)
|
||||
@@ -523,6 +524,7 @@ final class ConnectionEventHandler implements TS3Listener {
|
||||
ClientEntry c = conn.getModel().getClient(clientId);
|
||||
if (c != null) c.removeServerGroup(e.getServerGroupId());
|
||||
boolean self = clientId == conn.getSelfClientId();
|
||||
if (self) conn.requestMovePower();
|
||||
conn.sound(self
|
||||
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER,
|
||||
SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_SERVER)
|
||||
@@ -539,6 +541,7 @@ final class ConnectionEventHandler implements TS3Listener {
|
||||
ClientEntry c = conn.getModel().getClient(e.getClientId());
|
||||
if (c != null) c.channelGroupId = e.getChannelGroupId();
|
||||
boolean self = e.getClientId() == conn.getSelfClientId();
|
||||
if (self) conn.requestMovePower();
|
||||
String groupName = conn.getModel().channelGroupName(e.getChannelGroupId());
|
||||
conn.sound(self
|
||||
? byInvoker(e, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER,
|
||||
|
||||
@@ -32,6 +32,8 @@ public final class ServerModel {
|
||||
private int nextPermissionId = FIRST_PERMISSION_ID;
|
||||
/** Permission id -> the local client's resolved value, from {@code notifyclientneededpermissions}. */
|
||||
private final Map<Integer, Integer> selfPermissionValues = new LinkedHashMap<>();
|
||||
/** The local client's {@code i_client_move_power}, which the needed permissions leave out; 0 until known. */
|
||||
private int selfMovePower;
|
||||
private String serverName = "TeamSpeak Server";
|
||||
/** The {@code virtualserver_*} properties from {@code initserver}, kept up to date by server edits. */
|
||||
private final Map<String, String> serverProperties = new HashMap<>();
|
||||
@@ -47,6 +49,7 @@ public final class ServerModel {
|
||||
permissionIds.clear();
|
||||
nextPermissionId = FIRST_PERMISSION_ID;
|
||||
selfPermissionValues.clear();
|
||||
selfMovePower = 0;
|
||||
serverProperties.clear();
|
||||
defaultChannelGroupId = 0;
|
||||
}
|
||||
@@ -154,6 +157,14 @@ public final class ServerModel {
|
||||
return value == null ? 0 : value;
|
||||
}
|
||||
|
||||
public synchronized void setSelfMovePower(int power) {
|
||||
selfMovePower = power;
|
||||
}
|
||||
|
||||
public synchronized int selfMovePower() {
|
||||
return selfMovePower;
|
||||
}
|
||||
|
||||
// ---- what the local client may do ----
|
||||
|
||||
/**
|
||||
|
||||
@@ -352,6 +352,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
loadChatLogs();
|
||||
ui.onConnected();
|
||||
requestPermissionNames();
|
||||
requestMovePower();
|
||||
|
||||
ui.onStatus("Retrieving channels…");
|
||||
syncAll();
|
||||
@@ -728,6 +729,23 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
attempt(sock -> sock.executeCommand(new SingleCommand("permissionlist", ProtocolRole.CLIENT)).complete());
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for our own {@code i_client_move_power}, which {@code notifyclientneededpermissions}
|
||||
* never carries. Asking needs {@code b_client_permissionoverview_own}; refused, we take it
|
||||
* as none, which is what the groups that lack the one usually have of the other.
|
||||
*/
|
||||
void requestMovePower() {
|
||||
submit(sock -> {
|
||||
int power = 0;
|
||||
for (SingleCommand answer : sock.executeCommand(new SingleCommand("permget", ProtocolRole.CLIENT,
|
||||
new CommandSingleParameter("permsid", "i_client_move_power"))).get()) {
|
||||
String value = answer.toMap().get("permvalue");
|
||||
if (value != null) power = Integer.parseInt(value);
|
||||
}
|
||||
model.setSelfMovePower(power);
|
||||
}, e -> model.setSelfMovePower(0));
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
act("Could not " + (assign ? "assign" : "remove") + " server group: ", sock -> {
|
||||
|
||||
@@ -383,6 +383,14 @@ public final class ServerSession {
|
||||
return !model.hasPermissionNames() || model.selfPermissionValue("b_client_ban_list") > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether we have any {@code i_client_move_power}; whether it beats a client's needed
|
||||
* move power only the server knows, so it is left to refuse.
|
||||
*/
|
||||
public boolean canMoveClients() {
|
||||
return conn.isConnected() && conn.getModel().selfMovePower() != 0;
|
||||
}
|
||||
|
||||
// ---- connection events ----
|
||||
|
||||
private void setStatus(String text) {
|
||||
|
||||
Reference in New Issue
Block a user