Kick, ban and set groups from the Android client sheet, with a ban list

The client sheet offers kicking from the channel or the server (asking to
confirm, with an optional reason), banning with a reason and duration, and
the client's server groups and channel group. The server sheet opens the ban
list: search, own bans only, and adding, editing and deleting bans, with the
reason presets the desktop keeps.

Kick and ban powers, like move power, never come with the needed
permissions, so core now asks for all four in one piped permget. The ban
permission checks, a ban's summary, the duration units, the group choices
for a client and the kick reason limit move into core, and Swing uses them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 21:27:29 +00:00
parent 76f6a9feed
commit cf1db61a4a
22 changed files with 887 additions and 116 deletions

View File

@@ -3,6 +3,9 @@ package com.ts3client.android
import com.ts3client.contacts.ContactType import com.ts3client.contacts.ContactType
import com.ts3client.net.ChannelNode import com.ts3client.net.ChannelNode
import com.ts3client.net.Details import com.ts3client.net.Details
import com.ts3client.net.GroupChoice
import com.ts3client.net.ban.BanEntry
import com.ts3client.net.ban.BanRule
import com.ts3client.net.ConnectionListener.ChatScope import com.ts3client.net.ConnectionListener.ChatScope
import com.ts3client.session.ServerSession import com.ts3client.session.ServerSession
import com.ts3client.text.BBCode import com.ts3client.text.BBCode
@@ -168,6 +171,71 @@ class ServerController internal constructor(
conn.model.getClient(clientId)?.let { conn.setClientVolumeModifier(it, decibels) } conn.model.getClient(clientId)?.let { conn.setClientVolumeModifier(it, decibels) }
} }
// ---- moderation ----
val canKickFromChannel: Boolean get() = session.canKickFromChannel()
val canKickFromServer: Boolean get() = session.canKickFromServer()
val canBanClients: Boolean get() = session.canBanClients()
val canViewBanList: Boolean get() = session.canViewBanList()
val canCreateBans: Boolean get() = session.isConnected && conn.model.permits("b_client_ban_create")
/** The longest ban we may hand out in seconds; 0 or -1 for no limit. */
val maxBanSeconds: Long get() = conn.model.maxBanSeconds()
fun kickFromChannel(clientId: Int, reason: String) {
if (session.isConnected) conn.kickFromChannel(clientId, reason)
}
fun kickFromServer(clientId: Int, reason: String) {
if (session.isConnected) conn.kickFromServer(clientId, reason)
}
/** Bans a client for [seconds], or for good with 0. */
fun ban(clientId: Int, seconds: Long, reason: String) {
if (session.isConnected) conn.banClient(clientId, seconds, reason)
}
fun serverGroupChoices(clientId: Int): List<GroupChoice> =
conn.model.getClient(clientId)?.let(conn.model::serverGroupChoices).orEmpty()
fun channelGroupChoices(clientId: Int): List<GroupChoice> =
conn.model.getClient(clientId)?.let(conn.model::channelGroupChoices).orEmpty()
fun setServerGroup(clientId: Int, groupId: Int, assign: Boolean) {
val client = conn.model.getClient(clientId) ?: return
if (session.isConnected) conn.setClientServerGroup(client.databaseId, groupId, assign)
}
/** Gives a client [groupId] in the channel they are in. */
fun setChannelGroup(clientId: Int, groupId: Int) {
val client = conn.model.getClient(clientId) ?: return
if (session.isConnected) conn.setClientChannelGroup(client.databaseId, client.channelId, groupId)
}
// ---- ban list ----
/** The server's bans, or why they could not be read. */
suspend fun banList(): Result<List<BanEntry>> = suspendCoroutine { done ->
conn.requestBanList { bans, error ->
done.resume(if (error == null) Result.success(bans) else Result.failure(IllegalStateException(error)))
}
}
/** Each of these answers null once done, else why not. */
suspend fun addBan(rule: BanRule): String? = suspendCoroutine { done -> conn.addBan(rule) { done.resume(it) } }
suspend fun replaceBan(banId: Int, rule: BanRule): String? =
suspendCoroutine { done -> conn.replaceBan(banId, rule) { done.resume(it) } }
suspend fun deleteBans(banIds: Collection<Int>): String? =
suspendCoroutine { done -> conn.deleteBans(banIds) { done.resume(it) } }
fun canDeleteBans(bans: Collection<BanEntry>): Boolean = conn.model.canDeleteBans(bans, conn.selfUniqueId)
fun canEditBan(ban: BanEntry): Boolean = conn.model.canEditBan(ban, conn.selfUniqueId)
fun isOwnBan(ban: BanEntry): Boolean = ban.createdBy(conn.selfUniqueId)
/** Uploads [data] as our avatar; null once it is set, else why not. */ /** Uploads [data] as our avatar; null once it is set, else why not. */
suspend fun setAvatar(data: ByteArray): String? { suspend fun setAvatar(data: ByteArray): String? {
if (!session.isConnected) return "Not connected" if (!session.isConnected) return "Not connected"

View File

@@ -131,6 +131,8 @@ data class ServerTree(
.map { it.iconId }.filter { it != 0L }, .map { it.iconId }.filter { it != 0L },
avatarFlag = c.avatarFlag.orEmpty(), avatarFlag = c.avatarFlag.orEmpty(),
country = c.country.orEmpty(), country = c.country.orEmpty(),
serverGroupIds = c.serverGroupIds.toList(),
channelGroupId = c.channelGroupId,
) )
} }
} }
@@ -178,4 +180,6 @@ data class ClientItem(
val avatarFlag: String, val avatarFlag: String,
/** The code of the country the server places the client in; empty when it cannot tell. */ /** The code of the country the server places the client in; empty when it cannot tell. */
val country: String, val country: String,
val serverGroupIds: List<Int>,
val channelGroupId: Int,
) )

View File

@@ -10,6 +10,7 @@ import android.os.Looper
import com.ts3client.android.audio.AndroidAudioBackend import com.ts3client.android.audio.AndroidAudioBackend
import com.ts3client.android.audio.AudioRoute import com.ts3client.android.audio.AudioRoute
import com.ts3client.audio.AudioDevice import com.ts3client.audio.AudioDevice
import com.ts3client.config.BanReasonPresets
import com.ts3client.config.Bookmark import com.ts3client.config.Bookmark
import com.ts3client.config.Bookmarks import com.ts3client.config.Bookmarks
import com.ts3client.config.IdentityStore import com.ts3client.config.IdentityStore
@@ -79,6 +80,8 @@ class SessionController(private val context: Context) {
internal val sounds = SoundNotifier(settings).also { it.setPlayer(soundPlayer) } internal val sounds = SoundNotifier(settings).also { it.setPlayer(soundPlayer) }
internal val notifier = MessageNotifier(context) internal val notifier = MessageNotifier(context)
internal val contactStore = ContactStore.load() internal val contactStore = ContactStore.load()
/** The reasons offered when adding a ban; shared by every server. */
val banReasons: BanReasonPresets by lazy { BanReasonPresets.load() }
internal val network = NetworkFollower(context) internal val network = NetworkFollower(context)
private val main = Handler(Looper.getMainLooper()) private val main = Handler(Looper.getMainLooper())

View File

@@ -0,0 +1,313 @@
package com.ts3client.android.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.ts3client.android.ServerController
import com.ts3client.config.BanReasonPresets
import com.ts3client.net.ban.BanEntry
import com.ts3client.net.ban.BanPattern
import com.ts3client.net.ban.BanRule
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
/** What the ban list shows instead of itself: a ban's editor, a new or an existing ban's. */
private sealed interface BanEdit {
data object New : BanEdit
data class Existing(val ban: BanEntry) : BanEdit
}
/** The server's ban list, as TeamSpeak's Ban List window: searchable, own bans apart, add, edit and remove. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BanListScreen(server: ServerController, presets: BanReasonPresets, onBack: () -> Unit) {
val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() }
var bans by remember { mutableStateOf<List<BanEntry>?>(null) }
var status by remember { mutableStateOf("Loading…") }
/** Bumped to read the list again. */
var generation by remember { mutableIntStateOf(0) }
var query by rememberSaveable { mutableStateOf("") }
var onlyOwn by rememberSaveable { mutableStateOf(false) }
var shown by remember { mutableStateOf<BanEntry?>(null) }
var edit by remember { mutableStateOf<BanEdit?>(null) }
LaunchedEffect(generation) {
status = "Loading…"
server.banList()
.onSuccess {
bans = it
status = if (it.size == 1) "1 ban" else "${it.size} bans"
}
.onFailure {
bans = emptyList()
status = "Failed to request ban list: ${it.message}"
}
}
/** Reports a failed change, or re-reads the list to show what the server now holds. */
val change = { action: suspend () -> String?, failure: String ->
scope.launch {
val error = action()
if (error != null) snackbar.showSnackbar("$failure: $error")
generation++
}
Unit
}
edit?.let { e ->
val existing = (e as? BanEdit.Existing)?.ban
BanEditor(existing, presets, server.maxBanSeconds, onBack = { edit = null }) { rule ->
edit = null
if (existing == null) change({ server.addBan(rule) }, "Error adding ban")
else change({ server.replaceBan(existing.id, rule) }, "Error editing ban")
}
return
}
SubScreen(
"Ban list", onBack,
actions = {
if (server.canCreateBans) IconButton(onClick = { edit = BanEdit.New }) { PackIcon("ADD", "Add") }
IconButton(onClick = { generation++ }) { PackIcon("REFRESH", "Reload") }
},
snackbar = snackbar,
) { padding ->
val needle = query.trim().lowercase(Locale.ROOT)
val filtered = remember(bans, needle, onlyOwn) {
bans.orEmpty().filter { ban ->
(!onlyOwn || server.isOwnBan(ban)) && (needle.isEmpty() || searchText(ban).contains(needle))
}
}
Column(Modifier.padding(padding).imePadding().fillMaxSize()) {
Row(Modifier.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(query, { query = it }, Modifier.weight(1f), placeholder = { Text("Search") },
singleLine = true, leadingIcon = { PackIcon("SEARCH", null) })
FilterChip(onlyOwn, { onlyOwn = !onlyOwn }, label = { Text("Own") })
}
Text(status, Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
LazyColumn(Modifier.weight(1f)) {
items(filtered, key = { it.id }) { ban ->
ListItem(
headlineContent = { Text(ban.targets(), maxLines = 2, overflow = TextOverflow.Ellipsis) },
supportingContent = {
Column {
if (ban.reason.isNotEmpty()) Text(ban.reason, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${ban.invokerName} · ${expiry(ban)}", style = MaterialTheme.typography.bodySmall)
}
},
modifier = Modifier.clickable { shown = ban },
)
HorizontalDivider()
}
}
}
}
shown?.let { ban ->
BanSheet(
ban,
onDismiss = { shown = null },
onEdit = if (server.canEditBan(ban)) ({ shown = null; edit = BanEdit.Existing(ban) }) else null,
onDelete = if (server.canDeleteBans(listOf(ban))) ({
shown = null
change({ server.deleteBans(listOf(ban.id)) }, "Error deleting ban")
}) else null,
)
}
}
private fun searchText(ban: BanEntry): String =
listOf(ban.targets(), ban.reason, ban.invokerName, date(ban.createdEpochSeconds), expiry(ban))
.joinToString("\n").lowercase(Locale.ROOT)
private val DATE: DateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)
private fun date(epochSeconds: Long): String =
if (epochSeconds <= 0) "" else DATE.format(Instant.ofEpochSecond(epochSeconds).atZone(ZoneId.systemDefault()))
private fun expiry(ban: BanEntry): String = if (ban.isPermanent) "Never expires" else "Expires ${date(ban.expiresEpochSeconds())}"
/** Everything about one ban, and what may be done with it. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BanSheet(ban: BanEntry, onDismiss: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) {
var confirmDelete by remember { mutableStateOf(false) }
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 16.dp)) {
Column(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Fact("Name", ban.name)
Fact("IP", ban.ip)
Fact("Unique ID", ban.uid)
Fact("Last nickname", ban.lastNickname)
Fact("myTeamSpeak ID", ban.mytsid)
Fact("Reason", ban.reason)
Fact("Creator", ban.invokerName)
Fact("Created", date(ban.createdEpochSeconds))
Fact("Expires", if (ban.isPermanent) "Never" else date(ban.expiresEpochSeconds()))
Fact("Enforced", ban.enforcements.toString())
}
onEdit?.let { Action("EDIT", "Edit", onClick = it) }
onDelete?.let { Action("DELETE", "Delete") { confirmDelete = true } }
}
}
if (confirmDelete && onDelete != null) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text("Delete ban?") },
text = { Text(ban.targets()) },
confirmButton = { TextButton(onClick = { confirmDelete = false; onDelete() }) { Text("Delete") } },
dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
)
}
}
@Composable
private fun Fact(label: String, value: String) {
if (value.isEmpty()) return
Column {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyMedium)
}
}
/** Adds a ban, or rewrites [existing]: what it matches, why, and for how long. */
@Composable
private fun BanEditor(
existing: BanEntry?,
presets: BanReasonPresets,
maxSeconds: Long,
onBack: () -> Unit,
onSave: (BanRule) -> Unit,
) {
val decoded = remember { BanPattern.decode(existing?.ip, existing?.name) }
var ip by remember { mutableStateOf(decoded.ip) }
var name by remember { mutableStateOf(decoded.name) }
var pattern by remember { mutableStateOf(if (existing == null) BanPattern.WILDCARD_IPV4 else decoded.pattern) }
var uid by remember { mutableStateOf(existing?.uid.orEmpty()) }
var mytsid by remember { mutableStateOf(existing?.mytsid.orEmpty()) }
var reason by remember { mutableStateOf(existing?.reason.orEmpty()) }
val duration = remember { BanDurationState(existing?.durationSeconds ?: DEFAULT_BAN_SECONDS) }
val rule = BanRule(pattern.ipToRegex(ip), pattern.nameToRegex(name), uid, mytsid, duration.seconds, reason)
val ipValid = BanPattern.isValidRegex(rule.ipRegex)
val nameValid = BanPattern.isValidRegex(rule.nameRegex)
val valid = rule.hasTarget() && ipValid && nameValid && duration.valid
SubScreen(
if (existing == null) "Add ban" else "Edit ban", onBack,
actions = { TextButton(onClick = { onSave(rule) }, enabled = valid) { Text("Save") } },
) { padding ->
Column(
Modifier.padding(padding).imePadding().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(ip, { ip = it }, Modifier.fillMaxWidth(), label = { Text("IP") }, singleLine = true,
isError = !ipValid)
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true,
isError = !nameValid)
PatternField(pattern) { pattern = it }
OutlinedTextField(uid, { uid = it }, Modifier.fillMaxWidth(), label = { Text("Unique ID") }, singleLine = true)
OutlinedTextField(mytsid, { mytsid = it }, Modifier.fillMaxWidth(), label = { Text("myTeamSpeak ID") },
singleLine = true)
ReasonField(reason, { reason = it }, presets)
BanDurationField(duration, maxSeconds)
}
}
}
/** How the IP and name are written: wildcards, fixed strings or regular expressions. */
@Composable
private fun PatternField(pattern: BanPattern, onSelect: (BanPattern) -> Unit) {
var open by remember { mutableStateOf(false) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Interpret as", Modifier.weight(1f))
Box {
OutlinedButton(onClick = { open = true }) { Text(pattern.label()) }
DropdownMenu(open, { open = false }) {
for (p in BanPattern.entries) DropdownMenuItem({ Text(p.label()) }, onClick = { open = false; onSelect(p) })
}
}
}
}
/** The reason, with the saved presets to pick from and the reason typed to save as one. */
@Composable
private fun ReasonField(reason: String, onChange: (String) -> Unit, presets: BanReasonPresets) {
var open by remember { mutableStateOf(false) }
var saved by remember { mutableStateOf(presets.all().toList()) }
Box {
Row(verticalAlignment = Alignment.Top) {
LimitedField(reason, onChange, "Reason", BanRule.REASON_LIMIT, Modifier.weight(1f))
IconButton(onClick = { open = true }, Modifier.padding(top = 8.dp)) { Icon(Icons.Filled.ArrowDropDown, "Presets") }
}
DropdownMenu(open, { open = false }, Modifier.align(Alignment.TopEnd)) {
for (preset in saved) {
DropdownMenuItem({ Text(preset, maxLines = 1, overflow = TextOverflow.Ellipsis) },
onClick = { open = false; onChange(preset.take(BanRule.REASON_LIMIT)) },
trailingIcon = {
IconButton(onClick = {
presets.remove(preset)
presets.save()
saved = presets.all().toList()
}) { PackIcon("DELETE", "Remove preset") }
})
}
if (saved.isNotEmpty()) HorizontalDivider()
DropdownMenuItem({ Text("Save as preset") }, enabled = reason.isNotBlank(), onClick = {
open = false
presets.add(reason)
presets.save()
saved = presets.all().toList()
})
}
}
}

View File

@@ -0,0 +1,225 @@
package com.ts3client.android.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.ts3client.android.ClientItem
import com.ts3client.android.ServerController
import com.ts3client.net.GroupChoice
import com.ts3client.net.TeamspeakConnection
import com.ts3client.net.ban.BanRule
import com.ts3client.net.ban.BanUnit
/** Asks to confirm a kick, with an optional reason. */
@Composable
fun KickPrompt(title: String, onDismiss: () -> Unit, onKick: (String) -> Unit) {
var reason by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { LimitedField(reason, { reason = it }, "Reason", TeamspeakConnection.KICK_REASON_LIMIT) },
confirmButton = { TextButton(onClick = { onKick(reason.trim()) }) { Text("Kick") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
/** Asks for a ban's reason and length. */
@Composable
fun BanPrompt(name: String, maxSeconds: Long, onDismiss: () -> Unit, onBan: (seconds: Long, reason: String) -> Unit) {
var reason by remember { mutableStateOf("") }
val duration = remember { BanDurationState(DEFAULT_BAN_SECONDS) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Ban $name") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
LimitedField(reason, { reason = it }, "Reason", BanRule.REASON_LIMIT)
BanDurationField(duration, maxSeconds)
}
},
confirmButton = {
TextButton(onClick = { onBan(duration.seconds, reason.trim()) }, enabled = duration.valid) { Text("Ban") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
/** Thirty minutes, as the official client starts its ban dialog. */
internal const val DEFAULT_BAN_SECONDS = 30 * 60L
/** A one-line field that takes no more than [limit] characters, counting them as they come. */
@Composable
internal fun LimitedField(value: String, onChange: (String) -> Unit, label: String, limit: Int, modifier: Modifier = Modifier) {
OutlinedTextField(
value, { onChange(it.take(limit)) },
modifier.fillMaxWidth(),
label = { Text(label) },
singleLine = true,
supportingText = { Text("${value.length}/$limit", Modifier.fillMaxWidth(), textAlign = TextAlign.End) },
)
}
/** A ban's length as typed: an amount of a [BanUnit]. */
internal class BanDurationState(seconds: Long) {
var unit by mutableStateOf(BanUnit.largestFor(seconds))
var amount by mutableStateOf(BanUnit.largestFor(seconds).amountOf(seconds).takeIf { it > 0 }?.toString() ?: "30")
val valid: Boolean get() = unit == BanUnit.PERMANENT || (amount.toLongOrNull() ?: 0) > 0
/** The length in seconds; 0 for permanent. */
val seconds: Long get() = unit.toSeconds(amount.toLongOrNull() ?: 0)
fun set(seconds: Long) {
unit = BanUnit.largestFor(seconds)
if (unit != BanUnit.PERMANENT) amount = unit.amountOf(seconds).toString()
}
}
/** The amount, its unit, and a jump to the longest ban the server lets us hand out. */
@Composable
internal fun BanDurationField(state: BanDurationState, maxSeconds: Long, modifier: Modifier = Modifier) {
var units by remember { mutableStateOf(false) }
Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
if (state.unit == BanUnit.PERMANENT) "" else state.amount,
{ text -> state.amount = text.filter(Char::isDigit).take(9) },
Modifier.width(120.dp),
enabled = state.unit != BanUnit.PERMANENT,
label = { Text("Duration") },
singleLine = true,
isError = !state.valid,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
Box(Modifier.weight(1f)) {
OutlinedButton(onClick = { units = true }, Modifier.fillMaxWidth()) { Text(state.unit.label()) }
DropdownMenu(units, { units = false }) {
for (unit in BanUnit.entries) {
DropdownMenuItem({ Text(unit.label()) }, onClick = { units = false; state.unit = unit })
}
}
}
if (maxSeconds != 0L) TextButton(onClick = { state.set(maxSeconds) }) { Text("Max") }
}
}
/**
* A client's groups to tick: several server groups, or with [single] the one channel group.
* Groups we have no power over show greyed out; a change shows once the server made it.
*/
@Composable
fun GroupsDialog(
title: String,
choices: List<GroupChoice>,
single: Boolean,
onDismiss: () -> Unit,
onChange: (groupId: Int, assign: Boolean) -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
LazyColumn {
items(choices, key = { it.group().id }) { choice ->
val group = choice.group()
// A client always has one channel group: picking another is the only change.
val changeable = choice.allowed() && !(single && choice.assigned())
val toggle = { if (changeable) onChange(group.id, !choice.assigned()) }
Row(
Modifier.fillMaxWidth()
.clickable(enabled = changeable, onClick = toggle)
.alpha(if (choice.allowed()) 1f else 0.38f)
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (single) {
RadioButton(choice.assigned(), onClick = toggle, enabled = choice.allowed())
} else {
Checkbox(choice.assigned(), onCheckedChange = { toggle() }, enabled = choice.allowed())
}
Box(Modifier.width(24.dp), contentAlignment = Alignment.Center) {
if (group.iconId != 0L) ServerIcon(group.iconId)
}
Text(group.name, Modifier.padding(start = 8.dp))
}
}
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } },
)
}
/** The kicks, the ban and the groups of [client], as far as we may use them; [onDone] once one went out. */
@Composable
fun ClientModeration(server: ServerController, client: ClientItem, onDone: () -> Unit) {
var kick by remember { mutableStateOf<Boolean?>(null) }
var banning by remember { mutableStateOf(false) }
var groups by remember { mutableStateOf<Boolean?>(null) }
val serverGroups = remember(client) { server.serverGroupChoices(client.id) }
val channelGroups = remember(client) { server.channelGroupChoices(client.id) }
if (!client.self) {
if (server.canKickFromChannel) Action("KICK_FROM_CHANNEL", "Kick from channel") { kick = false }
if (server.canKickFromServer) Action("KICK_FROM_SERVER", "Kick from server") { kick = true }
if (server.canBanClients) Action("BAN_CLIENT", "Ban") { banning = true }
}
if (serverGroups.any { it.allowed() }) Action("PERMISSIONS_SERVER_GROUPS", "Server groups") { groups = true }
if (channelGroups.any { it.allowed() && !it.assigned() }) {
Action("PERMISSIONS_CHANNEL_GROUPS", "Channel group") { groups = false }
}
kick?.let { fromServer ->
KickPrompt(
if (fromServer) "Kick ${client.name} from the server?" else "Kick ${client.name} from the channel?",
onDismiss = { kick = null },
) { reason ->
kick = null
if (fromServer) server.kickFromServer(client.id, reason) else server.kickFromChannel(client.id, reason)
onDone()
}
}
if (banning) {
BanPrompt(client.name, server.maxBanSeconds, onDismiss = { banning = false }) { seconds, reason ->
banning = false
server.ban(client.id, seconds, reason)
onDone()
}
}
groups?.let { ofServer ->
GroupsDialog(
if (ofServer) "Server groups" else "Channel group",
if (ofServer) serverGroups else channelGroups,
single = !ofServer,
onDismiss = { groups = null },
) { groupId, assign ->
if (ofServer) server.setServerGroup(client.id, groupId, assign) else server.setChannelGroup(client.id, groupId)
}
}
}

View File

@@ -127,6 +127,7 @@ fun ServerScreen(
var reveal by remember { mutableStateOf<TreeRef?>(null) } var reveal by remember { mutableStateOf<TreeRef?>(null) }
var channelSheet by remember { mutableStateOf<Int?>(null) } var channelSheet by remember { mutableStateOf<Int?>(null) }
var serverSheet by remember { mutableStateOf(false) } var serverSheet by remember { mutableStateOf(false) }
var banList by rememberSaveable { mutableStateOf(false) }
/** The client to move, while a channel for them is being picked. */ /** The client to move, while a channel for them is being picked. */
var moving by remember { mutableStateOf<Int?>(null) } var moving by remember { mutableStateOf<Int?>(null) }
val infoUpdates by server.infoUpdates.collectAsStateWithLifecycle() val infoUpdates by server.infoUpdates.collectAsStateWithLifecycle()
@@ -201,6 +202,11 @@ fun ServerScreen(
} }
} }
if (banList) {
BanListScreen(server, vm.banReasons, onBack = { banList = false })
return
}
BackHandler(showChat) { showChat = false } BackHandler(showChat) { showChat = false }
BackHandler(moving != null && !showChat) { moving = null } BackHandler(moving != null && !showChat) { moving = null }
BackHandler(find.open) { find.open = false } BackHandler(find.open) { find.open = false }
@@ -337,6 +343,7 @@ fun ServerScreen(
showChat = false showChat = false
reveal = TreeRef(false, id) reveal = TreeRef(false, id)
}), }),
moderation = { ClientModeration(server, client) { clientSheet = null } },
) )
} }
} }
@@ -356,7 +363,8 @@ fun ServerScreen(
} }
if (serverSheet) { if (serverSheet) {
remember(tree, infoUpdates) { server.serverDetails() }?.let { remember(tree, infoUpdates) { server.serverDetails() }?.let {
ServerSheet(it, onLink, onDismiss = { serverSheet = false }) ServerSheet(it, onLink, onDismiss = { serverSheet = false },
onBanList = if (server.canViewBanList) ({ serverSheet = false; banList = true }) else null)
} }
} }
} }

View File

@@ -25,6 +25,8 @@ import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -49,10 +51,12 @@ internal fun SubScreen(
title: String, title: String,
onBack: () -> Unit, onBack: () -> Unit,
actions: @Composable RowScope.() -> Unit = {}, actions: @Composable RowScope.() -> Unit = {},
snackbar: SnackbarHostState? = null,
content: @Composable (PaddingValues) -> Unit, content: @Composable (PaddingValues) -> Unit,
) { ) {
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
Scaffold( Scaffold(
snackbarHost = { snackbar?.let { SnackbarHost(it) } },
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) },

View File

@@ -59,6 +59,8 @@ fun ClientSheet(
onMoveToOwnChannel: (() -> Unit)?, onMoveToOwnChannel: (() -> Unit)?,
/** Null when the sheet was opened from the tree. */ /** Null when the sheet was opened from the tree. */
onFindInTree: (() -> Unit)?, onFindInTree: (() -> Unit)?,
/** Kicks, bans and groups, as far as we may use them. */
moderation: @Composable () -> Unit = {},
) { ) {
var poking by remember { mutableStateOf(false) } var poking by remember { mutableStateOf(false) }
ModalBottomSheet(onDismissRequest = onDismiss) { ModalBottomSheet(onDismissRequest = onDismiss) {
@@ -87,11 +89,13 @@ fun ClientSheet(
onFindInTree?.let { Action("CHANNEL_SWITCH", "Find client in channel tree", onClick = it) } onFindInTree?.let { Action("CHANNEL_SWITCH", "Find client in channel tree", onClick = it) }
if (client.self) { if (client.self) {
Action("AWAY", if (away) "Back" else "Away") { onAway(!away) } Action("AWAY", if (away) "Back" else "Away") { onAway(!away) }
moderation()
} else { } else {
Action("PLAYER_CHAT", "Send message", onClick = onMessage) Action("PLAYER_CHAT", "Send message", onClick = onMessage)
Action("POKE", "Poke") { poking = true } Action("POKE", "Poke") { poking = true }
onMove?.let { Action("CHANNEL_SWITCH", "Move to channel…", onClick = it) } onMove?.let { Action("CHANNEL_SWITCH", "Move to channel…", onClick = it) }
onMoveToOwnChannel?.let { Action("MOVE_CLIENT_TO_OWN_CHANNEL", "Move to own channel", onClick = it) } onMoveToOwnChannel?.let { Action("MOVE_CLIENT_TO_OWN_CHANNEL", "Move to own channel", onClick = it) }
moderation()
if (client.contactType == null) { if (client.contactType == null) {
Row { Row {
Action("ADD_FRIEND", "Add as friend", Modifier.weight(1f)) { onAddContact(ContactType.FRIEND) } Action("ADD_FRIEND", "Add as friend", Modifier.weight(1f)) { onAddContact(ContactType.FRIEND) }
@@ -168,7 +172,7 @@ fun ChannelSheet(
/** The server's details: its version, who is on it, its welcome message. */ /** The server's details: its version, who is on it, its welcome message. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ServerSheet(details: Details, onLink: (String) -> Unit, onDismiss: () -> Unit) { fun ServerSheet(details: Details, onLink: (String) -> Unit, onDismiss: () -> Unit, onBanList: (() -> Unit)?) {
ModalBottomSheet(onDismissRequest = onDismiss) { ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 16.dp)) { Column(Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 16.dp)) {
Row(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, Row(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically,
@@ -177,12 +181,13 @@ fun ServerSheet(details: Details, onLink: (String) -> Unit, onDismiss: () -> Uni
Text(details.title(), style = MaterialTheme.typography.titleLarge) Text(details.title(), style = MaterialTheme.typography.titleLarge)
} }
DetailsSection(details, onLink) DetailsSection(details, onLink)
onBanList?.let { Action("BAN_LIST", "Ban list", onClick = it) }
} }
} }
} }
@Composable @Composable
private fun Action(iconKey: String, label: String, modifier: Modifier = Modifier.fillMaxWidth(), onClick: () -> Unit) { internal fun Action(iconKey: String, label: String, modifier: Modifier = Modifier.fillMaxWidth(), onClick: () -> Unit) {
ListItem( ListItem(
headlineContent = { Text(label) }, headlineContent = { Text(label) },
leadingContent = { PackIcon(iconKey, null) }, leadingContent = { PackIcon(iconKey, null) },

View File

@@ -508,7 +508,7 @@ final class ConnectionEventHandler implements TS3Listener {
ClientEntry c = conn.getModel().getClient(e.getClientId()); ClientEntry c = conn.getModel().getClient(e.getClientId());
if (c != null) c.addServerGroup(e.getServerGroupId()); if (c != null) c.addServerGroup(e.getServerGroupId());
boolean self = e.getClientId() == conn.getSelfClientId(); boolean self = e.getClientId() == conn.getSelfClientId();
if (self) conn.requestMovePower(); if (self) conn.requestSelfPowers();
conn.sound(self conn.sound(self
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, ? byInvoker(e, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER,
SoundEvent.YOU_SERVERGROUP_ADDED_BY_SERVER) SoundEvent.YOU_SERVERGROUP_ADDED_BY_SERVER)
@@ -526,7 +526,7 @@ final class ConnectionEventHandler implements TS3Listener {
ClientEntry c = conn.getModel().getClient(clientId); ClientEntry c = conn.getModel().getClient(clientId);
if (c != null) c.removeServerGroup(e.getServerGroupId()); if (c != null) c.removeServerGroup(e.getServerGroupId());
boolean self = clientId == conn.getSelfClientId(); boolean self = clientId == conn.getSelfClientId();
if (self) conn.requestMovePower(); if (self) conn.requestSelfPowers();
conn.sound(self conn.sound(self
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, ? byInvoker(e, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER,
SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_SERVER) SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_SERVER)
@@ -543,7 +543,7 @@ final class ConnectionEventHandler implements TS3Listener {
ClientEntry c = conn.getModel().getClient(e.getClientId()); ClientEntry c = conn.getModel().getClient(e.getClientId());
if (c != null) c.channelGroupId = e.getChannelGroupId(); if (c != null) c.channelGroupId = e.getChannelGroupId();
boolean self = e.getClientId() == conn.getSelfClientId(); boolean self = e.getClientId() == conn.getSelfClientId();
if (self) conn.requestMovePower(); if (self) conn.requestSelfPowers();
String groupName = conn.getModel().channelGroupName(e.getChannelGroupId()); String groupName = conn.getModel().channelGroupName(e.getChannelGroupId());
conn.sound(self conn.sound(self
? byInvoker(e, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER, ? byInvoker(e, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER,

View File

@@ -0,0 +1,5 @@
package com.ts3client.net;
/** A group as offered for one client: whether they are in it, and whether we may change that. */
public record GroupChoice(Group group, boolean assigned, boolean allowed) {
}

View File

@@ -1,6 +1,9 @@
package com.ts3client.net; package com.ts3client.net;
import com.ts3client.net.ban.BanEntry;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -32,8 +35,8 @@ public final class ServerModel {
private int nextPermissionId = FIRST_PERMISSION_ID; private int nextPermissionId = FIRST_PERMISSION_ID;
/** Permission id -> the local client's resolved value, from {@code notifyclientneededpermissions}. */ /** Permission id -> the local client's resolved value, from {@code notifyclientneededpermissions}. */
private final Map<Integer, Integer> selfPermissionValues = new LinkedHashMap<>(); 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. */ /** The local client's {@link #SELF_POWERS}, which the needed permissions leave out; absent until known. */
private int selfMovePower; private final Map<String, Integer> selfPowers = new HashMap<>();
private String serverName = "TeamSpeak Server"; private String serverName = "TeamSpeak Server";
/** The {@code virtualserver_*} properties from {@code initserver}, kept up to date by server edits. */ /** The {@code virtualserver_*} properties from {@code initserver}, kept up to date by server edits. */
private final Map<String, String> serverProperties = new HashMap<>(); private final Map<String, String> serverProperties = new HashMap<>();
@@ -49,7 +52,7 @@ public final class ServerModel {
permissionIds.clear(); permissionIds.clear();
nextPermissionId = FIRST_PERMISSION_ID; nextPermissionId = FIRST_PERMISSION_ID;
selfPermissionValues.clear(); selfPermissionValues.clear();
selfMovePower = 0; selfPowers.clear();
serverProperties.clear(); serverProperties.clear();
defaultChannelGroupId = 0; defaultChannelGroupId = 0;
} }
@@ -157,16 +160,56 @@ public final class ServerModel {
return value == null ? 0 : value; return value == null ? 0 : value;
} }
public synchronized void setSelfMovePower(int power) { /**
selfMovePower = power; * The powers {@code notifyclientneededpermissions} never carries, not even for an admin;
* they have to be asked for with {@code permget}.
*/
public static final List<String> SELF_POWERS = List.of("i_client_move_power",
"i_client_kick_from_channel_power", "i_client_kick_from_server_power", "i_client_ban_power");
/** Takes on the answer to asking for {@link #SELF_POWERS}; those it leaves out count as 0. */
public synchronized void setSelfPowers(Map<String, Integer> powers) {
selfPowers.clear();
selfPowers.putAll(powers);
} }
public synchronized int selfMovePower() { /** One of {@link #SELF_POWERS}; 0 until known, or when the server would not tell. */
return selfMovePower; public synchronized int selfPower(String name) {
return selfPowers.getOrDefault(name, 0);
} }
// ---- what the local client may do ---- // ---- what the local client may do ----
/**
* Whether we hold a permission. Until the server has named its permissions nothing can
* be resolved, so everything is offered and the server is left to refuse.
*/
public synchronized boolean permits(String permission) {
return !hasPermissionNames() || selfPermissionValue(permission) > 0;
}
/**
* Whether we may delete all of {@code bans}: any ban with {@code b_client_ban_delete},
* only our own with {@code b_client_ban_delete_own}.
*
* @param selfUid our unique identifier, which is how a ban names its creator
*/
public synchronized boolean canDeleteBans(Collection<BanEntry> bans, String selfUid) {
if (permits("b_client_ban_delete")) return true;
if (!permits("b_client_ban_delete_own")) return false;
return bans.stream().allMatch(b -> b.createdBy(selfUid));
}
/** Whether we may rewrite {@code ban}: that is deleting it and creating its replacement. */
public synchronized boolean canEditBan(BanEntry ban, String selfUid) {
return permits("b_client_ban_create") && canDeleteBans(List.of(ban), selfUid);
}
/** {@code i_client_ban_max_bantime}: the longest ban we may hand out in seconds; 0 or -1 for no limit. */
public synchronized long maxBanSeconds() {
return selfPermissionValue("i_client_ban_max_bantime");
}
/** /**
* Whether the local client has enough power to assign {@code group}, or to take it * Whether the local client has enough power to assign {@code group}, or to take it
* away when {@code assigned}: the add power always gates it, and the remove power * away when {@code assigned}: the add power always gates it, and the remove power
@@ -180,6 +223,26 @@ public final class ServerModel {
selfPermissionValue("i_group_member_remove_power"))); selfPermissionValue("i_group_member_remove_power")));
} }
/** Every regular server group, with whether {@code client} is in it and whether we may change that. */
public synchronized List<GroupChoice> serverGroupChoices(ClientEntry client) {
List<GroupChoice> out = new ArrayList<>();
for (Group g : allServerGroups()) {
boolean assigned = client.inServerGroup(g.id);
out.add(new GroupChoice(g, assigned, canToggleGroup(g, assigned)));
}
return out;
}
/** Every regular channel group, with whether it is {@code client}'s and whether we may make it so. */
public synchronized List<GroupChoice> channelGroupChoices(ClientEntry client) {
List<GroupChoice> out = new ArrayList<>();
for (Group g : allChannelGroups()) {
boolean assigned = client.channelGroupId == g.id;
out.add(new GroupChoice(g, assigned, canToggleGroup(g, assigned)));
}
return out;
}
private static boolean hasPower(int needed, int own) { private static boolean hasPower(int needed, int own) {
if (own == -1) return true; if (own == -1) return true;
if (needed == -1) return false; if (needed == -1) return false;

View File

@@ -37,6 +37,7 @@ import java.net.InetSocketAddress;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@@ -57,6 +58,9 @@ public final class TeamspeakConnection implements TS3Listener {
/** Upper bound for a group icon; anything larger is not an icon. Also used by {@link ChannelAdmin}. */ /** Upper bound for a group icon; anything larger is not an icon. Also used by {@link ChannelAdmin}. */
static final int MAX_ICON_BYTES = 1024 * 1024; static final int MAX_ICON_BYTES = 1024 * 1024;
/** The longest reason the protocol carries with a kick. */
public static final int KICK_REASON_LIMIT = 40;
/** The protocol's reason ids for the two flavours of <b>clientkick</b>. */ /** The protocol's reason ids for the two flavours of <b>clientkick</b>. */
private static final int REASON_KICK_CHANNEL = 4; private static final int REASON_KICK_CHANNEL = 4;
private static final int REASON_KICK_SERVER = 5; private static final int REASON_KICK_SERVER = 5;
@@ -352,7 +356,7 @@ public final class TeamspeakConnection implements TS3Listener {
loadChatLogs(); loadChatLogs();
ui.onConnected(); ui.onConnected();
requestPermissionNames(); requestPermissionNames();
requestMovePower(); requestSelfPowers();
ui.onStatus("Retrieving channels…"); ui.onStatus("Retrieving channels…");
syncAll(); syncAll();
@@ -730,20 +734,25 @@ public final class TeamspeakConnection implements TS3Listener {
} }
/** /**
* Asks for our own {@code i_client_move_power}, which {@code notifyclientneededpermissions} * Asks for our own {@link ServerModel#SELF_POWERS}, which {@code notifyclientneededpermissions}
* never carries. Asking needs {@code b_client_permissionoverview_own}; refused, we take it * never carries. Asking needs {@code b_client_permissionoverview_own}; refused, we take them
* as none, which is what the groups that lack the one usually have of the other. * as none, which is what the groups that lack the one usually have of the others.
*/ */
void requestMovePower() { void requestSelfPowers() {
submit(sock -> { List<SingleCommand> rows = new ArrayList<>();
int power = 0; for (String name : ServerModel.SELF_POWERS) {
for (SingleCommand answer : sock.executeCommand(new SingleCommand("permget", ProtocolRole.CLIENT, rows.add(new SingleCommand("permget", ProtocolRole.CLIENT, new CommandSingleParameter("permsid", name)));
new CommandSingleParameter("permsid", "i_client_move_power"))).get()) {
String value = answer.toMap().get("permvalue");
if (value != null) power = Integer.parseInt(value);
} }
model.setSelfMovePower(power); submit(sock -> {
}, e -> model.setSelfMovePower(0)); Map<String, Integer> powers = new HashMap<>();
for (SingleCommand answer : sock.executeCommand(new MultiCommand("permget", ProtocolRole.CLIENT, rows)).get()) {
Map<String, String> fields = answer.toMap();
String name = fields.get("permsid");
String value = fields.get("permvalue");
if (name != null && value != null) powers.put(name, Integer.parseInt(value));
}
model.setSelfPowers(powers);
}, e -> model.setSelfPowers(Map.of()));
} }
/** Assigns or removes a server group for a client (by database id, as {@code servergroupaddclient} needs). */ /** Assigns or removes a server group for a client (by database id, as {@code servergroupaddclient} needs). */

View File

@@ -1,5 +1,7 @@
package com.ts3client.net.ban; package com.ts3client.net.ban;
import java.util.ArrayList;
import java.util.List;
import java.util.Map; import java.util.Map;
/** One entry of the server's ban list, as a {@code notifybanlist} event describes it. */ /** One entry of the server's ban list, as a {@code notifybanlist} event describes it. */
@@ -68,6 +70,16 @@ public final class BanEntry {
return uid != null && !uid.isEmpty() && uid.equals(invokerUid); return uid != null && !uid.isEmpty() && uid.equals(invokerUid);
} }
/** What the ban matches, as the official list sums it up: {@code name=…, ip=…, uid=… (last nickname), mytsid=…}. */
public String targets() {
List<String> parts = new ArrayList<>(4);
if (!name.isEmpty()) parts.add("name=" + name);
if (!ip.isEmpty()) parts.add("ip=" + ip);
if (!uid.isEmpty()) parts.add("uid=" + uid + (lastNickname.isEmpty() ? "" : " (" + lastNickname + ")"));
if (!mytsid.isEmpty()) parts.add("mytsid=" + mytsid);
return String.join(", ", parts);
}
/** The entry's targets and settings as a rule, which is what editing starts from. */ /** The entry's targets and settings as a rule, which is what editing starts from. */
public BanRule toRule() { public BanRule toRule() {
return new BanRule(ip, name, uid, mytsid, durationSeconds, reason); return new BanRule(ip, name, uid, mytsid, durationSeconds, reason);

View File

@@ -0,0 +1,45 @@
package com.ts3client.net.ban;
/**
* The units a ban's length is given in, as the official client asks for it: an amount of
* seconds, minutes, hours or days, or permanent, which takes no amount.
*/
public enum BanUnit {
SECONDS("seconds", 1),
MINUTES("minutes", 60),
HOURS("hours", 3600),
DAYS("days", 86400),
PERMANENT("permanent", 0);
private final String label;
private final long seconds;
BanUnit(String label, long seconds) {
this.label = label;
this.seconds = seconds;
}
public String label() {
return label;
}
/** The length of {@code amount} of this unit in seconds; 0 when permanent. */
public long toSeconds(long amount) {
return amount * seconds;
}
/** The largest unit that divides {@code seconds} evenly; a length of 0 or less is permanent. */
public static BanUnit largestFor(long seconds) {
if (seconds <= 0) return PERMANENT;
for (int i = PERMANENT.ordinal() - 1; i > 0; i--) {
if (seconds % values()[i].seconds == 0) return values()[i];
}
return SECONDS;
}
/** How many of this unit {@code seconds} is; 0 when permanent. */
public long amountOf(long seconds) {
return this == PERMANENT ? 0 : seconds / this.seconds;
}
}

View File

@@ -373,28 +373,23 @@ public final class ServerSession {
// ---- permissions ---- // ---- permissions ----
/** /** Whether the server grants us {@code b_client_ban_list}; offered while unknown. */
* Whether the server grants us {@code b_client_ban_list}. Until it has named its
* permissions nothing can be resolved, so the list is offered and the server left to refuse.
*/
public boolean canViewBanList() { public boolean canViewBanList() {
if (!conn.isConnected()) return false; return permits("b_client_ban_list");
ServerModel model = conn.getModel();
return !model.hasPermissionNames() || model.selfPermissionValue("b_client_ban_list") > 0;
} }
/** Whether the server lets us upload an avatar ({@code i_client_max_avatar_filesize}); offered while unknown. */ /** Whether the server lets us upload an avatar ({@code i_client_max_avatar_filesize}); offered while unknown. */
public boolean canSetAvatar() { public boolean canSetAvatar() {
if (!conn.isConnected()) return false; return permits("i_client_max_avatar_filesize");
ServerModel model = conn.getModel();
return !model.hasPermissionNames() || model.selfPermissionValue("i_client_max_avatar_filesize") > 0;
} }
/** Whether the server lets us delete other clients' avatars; offered while unknown. */ /** Whether the server lets us delete other clients' avatars; offered while unknown. */
public boolean canDeleteOtherAvatars() { public boolean canDeleteOtherAvatars() {
if (!conn.isConnected()) return false; return permits("b_client_avatar_delete_other");
ServerModel model = conn.getModel(); }
return !model.hasPermissionNames() || model.selfPermissionValue("b_client_avatar_delete_other") > 0;
private boolean permits(String permission) {
return conn.isConnected() && conn.getModel().permits(permission);
} }
/** Whether we publish an avatar on this server. */ /** Whether we publish an avatar on this server. */
@@ -404,12 +399,28 @@ public final class ServerSession {
return self != null && !self.avatarFlag.isEmpty(); return self != null && !self.avatarFlag.isEmpty();
} }
/**
* 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() { public boolean canMoveClients() {
return conn.isConnected() && conn.getModel().selfMovePower() != 0; return hasPower("i_client_move_power");
}
public boolean canKickFromChannel() {
return hasPower("i_client_kick_from_channel_power");
}
public boolean canKickFromServer() {
return hasPower("i_client_kick_from_server_power");
}
public boolean canBanClients() {
return hasPower("i_client_ban_power");
}
/**
* Whether we have any of a power; whether it beats the client's needed power only the
* server knows, so it is left to refuse.
*/
private boolean hasPower(String power) {
return conn.isConnected() && conn.getModel().selfPower(power) != 0;
} }
// ---- connection events ---- // ---- connection events ----

View File

@@ -0,0 +1,24 @@
package com.ts3client.net.ban;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class BanUnitTest {
@Test
void lengthsShowInTheLargestUnitThatDividesThem() {
assertEquals(BanUnit.DAYS, BanUnit.largestFor(2 * 86400));
assertEquals(BanUnit.HOURS, BanUnit.largestFor(90 * 60 * 2));
assertEquals(BanUnit.MINUTES, BanUnit.largestFor(120));
assertEquals(BanUnit.SECONDS, BanUnit.largestFor(61));
assertEquals(2, BanUnit.DAYS.amountOf(2 * 86400));
}
@Test
void noLengthIsPermanent() {
assertEquals(BanUnit.PERMANENT, BanUnit.largestFor(0));
assertEquals(BanUnit.PERMANENT, BanUnit.largestFor(-1));
assertEquals(0, BanUnit.PERMANENT.toSeconds(30));
}
}

View File

@@ -1,11 +1,14 @@
package com.ts3client.ui; package com.ts3client.ui;
import com.formdev.flatlaf.util.UIScale; import com.formdev.flatlaf.util.UIScale;
import com.ts3client.net.ban.BanUnit;
import javax.swing.Box; import javax.swing.Box;
import javax.swing.BoxLayout; import javax.swing.BoxLayout;
import javax.swing.DefaultListCellRenderer;
import javax.swing.JButton; import javax.swing.JButton;
import javax.swing.JComboBox; import javax.swing.JComboBox;
import javax.swing.JList;
import javax.swing.JPanel; import javax.swing.JPanel;
import javax.swing.JSpinner; import javax.swing.JSpinner;
import javax.swing.SpinnerNumberModel; import javax.swing.SpinnerNumberModel;
@@ -18,16 +21,8 @@ import java.awt.Component;
*/ */
final class BanDurationPicker extends JPanel { final class BanDurationPicker extends JPanel {
/**
* The units offered, with the seconds each one is worth. The last entry is the
* permanent ban, which has no length and so takes no amount.
*/
private static final String[] UNIT_NAMES = {"seconds", "minutes", "hours", "days", "permanent"};
private static final long[] UNIT_SECONDS = {1, 60, 3600, 86400, 0};
private static final int PERMANENT = UNIT_NAMES.length - 1;
private final JSpinner amount = new JSpinner(new SpinnerNumberModel(30, 1, 999_999_999, 1)); private final JSpinner amount = new JSpinner(new SpinnerNumberModel(30, 1, 999_999_999, 1));
private final JComboBox<String> unit = new JComboBox<>(UNIT_NAMES); private final JComboBox<BanUnit> unit = new JComboBox<>(BanUnit.values());
private final JButton max = new JButton("Max"); private final JButton max = new JButton("Max");
/** /**
@@ -38,7 +33,14 @@ final class BanDurationPicker extends JPanel {
setAlignmentX(Component.LEFT_ALIGNMENT); setAlignmentX(Component.LEFT_ALIGNMENT);
setOpaque(false); setOpaque(false);
unit.setSelectedIndex(1); // minutes unit.setSelectedItem(BanUnit.MINUTES);
unit.setRenderer(new DefaultListCellRenderer() {
@Override
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
boolean selected, boolean focused) {
return super.getListCellRendererComponent(list, ((BanUnit) value).label(), index, selected, focused);
}
});
unit.addActionListener(e -> amount.setEnabled(!isPermanent())); unit.addActionListener(e -> amount.setEnabled(!isPermanent()));
max.setToolTipText("Set duration to maximum value"); max.setToolTipText("Set duration to maximum value");
max.addActionListener(e -> setSeconds(maxSeconds)); max.addActionListener(e -> setSeconds(maxSeconds));
@@ -53,25 +55,23 @@ final class BanDurationPicker extends JPanel {
} }
boolean isPermanent() { boolean isPermanent() {
return UNIT_SECONDS[unit.getSelectedIndex()] == 0; return unit() == BanUnit.PERMANENT;
}
private BanUnit unit() {
return (BanUnit) unit.getSelectedItem();
} }
/** The ban's length in seconds, or 0 for a permanent ban. */ /** The ban's length in seconds, or 0 for a permanent ban. */
long getSeconds() { long getSeconds() {
if (isPermanent()) return 0; return unit().toSeconds(((Number) amount.getValue()).longValue());
return ((Number) amount.getValue()).longValue() * UNIT_SECONDS[unit.getSelectedIndex()];
} }
/** Shows a length in the largest unit that divides it evenly; 0 is permanent. */ /** Shows a length in the largest unit that divides it evenly; 0 is permanent. */
void setSeconds(long seconds) { void setSeconds(long seconds) {
if (seconds <= 0) { BanUnit fit = BanUnit.largestFor(seconds);
unit.setSelectedIndex(PERMANENT); unit.setSelectedItem(fit);
return; if (fit != BanUnit.PERMANENT) amount.setValue((int) Math.min(fit.amountOf(seconds), 999_999_999));
}
int i = PERMANENT - 1;
while (i > 0 && seconds % UNIT_SECONDS[i] != 0) i--;
unit.setSelectedIndex(i);
amount.setValue((int) Math.min(seconds / UNIT_SECONDS[i], 999_999_999));
} }
/** Called back whenever the length changes, however it changes. */ /** Called back whenever the length changes, however it changes. */

View File

@@ -4,7 +4,6 @@ import com.formdev.flatlaf.util.ScaledEmptyBorder;
import com.formdev.flatlaf.util.UIScale; import com.formdev.flatlaf.util.UIScale;
import com.ts3client.config.BanReasonPresets; import com.ts3client.config.BanReasonPresets;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import com.ts3client.net.ServerModel;
import com.ts3client.net.TeamspeakConnection; import com.ts3client.net.TeamspeakConnection;
import com.ts3client.net.ban.BanEntry; import com.ts3client.net.ban.BanEntry;
import com.ts3client.net.ban.BanRule; import com.ts3client.net.ban.BanRule;
@@ -422,33 +421,25 @@ final class BanListDialog extends JDialog {
// ---- permissions ---- // ---- permissions ----
/**
* Whether we hold a permission. Until the server has named its permissions nothing can
* be resolved, so everything is offered and the server is left to refuse.
*/
private boolean can(String permission) { private boolean can(String permission) {
ServerModel model = conn.getModel(); return conn.getModel().permits(permission);
return !model.hasPermissionNames() || model.selfPermissionValue(permission) > 0;
} }
private boolean canDelete(List<BanEntry> bans) { private boolean canDelete(List<BanEntry> bans) {
if (can("b_client_ban_delete")) return true; return conn.getModel().canDeleteBans(bans, conn.getSelfUniqueId());
if (!can("b_client_ban_delete_own")) return false;
String self = conn.getSelfUniqueId();
return bans.stream().allMatch(b -> b.createdBy(self));
} }
private void updateButtons() { private void updateButtons() {
List<BanEntry> selected = selectedBans(); List<BanEntry> selected = selectedBans();
addButton.setEnabled(can("b_client_ban_create")); addButton.setEnabled(can("b_client_ban_create"));
removeButton.setEnabled(!selected.isEmpty() && canDelete(selected)); removeButton.setEnabled(!selected.isEmpty() && canDelete(selected));
editButton.setEnabled(selected.size() == 1 && canDelete(selected) && can("b_client_ban_create")); editButton.setEnabled(selected.size() == 1 && conn.getModel().canEditBan(selected.get(0), conn.getSelfUniqueId()));
} }
// ---- actions ---- // ---- actions ----
private long maxBanSeconds() { private long maxBanSeconds() {
return conn.getModel().selfPermissionValue("i_client_ban_max_bantime"); return conn.getModel().maxBanSeconds();
} }
private void addBan() { private void addBan() {
@@ -558,7 +549,7 @@ final class BanListDialog extends JDialog {
String text(BanEntry ban, Column column) { String text(BanEntry ban, Column column) {
switch (column) { switch (column) {
case TARGETS: case TARGETS:
return targets(ban); return ban.targets();
case NAME: case NAME:
return ban.name; return ban.name;
case IP: case IP:
@@ -581,18 +572,6 @@ final class BanListDialog extends JDialog {
return ""; return "";
} }
} }
/** The official summary column: {@code name=…, ip=…, uid=… (last nickname), mytsid=…}. */
private String targets(BanEntry ban) {
List<String> parts = new ArrayList<>(4);
if (!ban.name.isEmpty()) parts.add("name=" + ban.name);
if (!ban.ip.isEmpty()) parts.add("ip=" + ban.ip);
if (!ban.uid.isEmpty()) {
parts.add("uid=" + ban.uid + (ban.lastNickname.isEmpty() ? "" : " (" + ban.lastNickname + ")"));
}
if (!ban.mytsid.isEmpty()) parts.add("mytsid=" + ban.mytsid);
return String.join(", ", parts);
}
} }
private static String date(long epochSeconds) { private static String date(long epochSeconds) {

View File

@@ -4,6 +4,7 @@ import com.ts3client.contacts.ContactType;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry; import com.ts3client.net.ClientEntry;
import com.ts3client.net.Group; import com.ts3client.net.Group;
import com.ts3client.net.GroupChoice;
import com.ts3client.net.ServerModel; import com.ts3client.net.ServerModel;
import javax.swing.JCheckBoxMenuItem; import javax.swing.JCheckBoxMenuItem;
@@ -128,12 +129,12 @@ final class ClientMenu {
menu.add(dialog); menu.add(dialog);
menu.addSeparator(); menu.addSeparator();
for (Group g : model.allServerGroups()) { for (GroupChoice choice : model.serverGroupChoices(client)) {
boolean assigned = client.inServerGroup(g.id); if (!choice.allowed()) continue;
if (!model.canToggleGroup(g, assigned)) continue; Group g = choice.group();
JCheckBoxMenuItem item = new JCheckBoxMenuItem(g.name, groupIcons.iconOf(g)); JCheckBoxMenuItem item = new JCheckBoxMenuItem(g.name, groupIcons.iconOf(g));
item.setSelected(assigned); item.setSelected(choice.assigned());
item.addActionListener(a -> actions.setClientServerGroup(client, g, !assigned)); item.addActionListener(a -> actions.setClientServerGroup(client, g, !choice.assigned()));
menu.add(item); menu.add(item);
} }
return menu; return menu;
@@ -144,12 +145,11 @@ final class ClientMenu {
JMenu menu = new JMenu("Set Channel Group"); JMenu menu = new JMenu("Set Channel Group");
menu.setIcon(Icons.of("PERMISSIONS_CHANNEL_GROUPS")); menu.setIcon(Icons.of("PERMISSIONS_CHANNEL_GROUPS"));
int defaultGroupId = model.defaultChannelGroupId(); int defaultGroupId = model.defaultChannelGroupId();
for (Group g : model.allChannelGroups()) { for (GroupChoice choice : model.channelGroupChoices(client)) {
if (g.id == defaultGroupId) continue; Group g = choice.group();
boolean assigned = client.channelGroupId == g.id; if (g.id == defaultGroupId || !choice.allowed()) continue;
if (!model.canToggleGroup(g, assigned)) continue;
JCheckBoxMenuItem item = new JCheckBoxMenuItem(g.name, groupIcons.iconOf(g)); JCheckBoxMenuItem item = new JCheckBoxMenuItem(g.name, groupIcons.iconOf(g));
item.setSelected(assigned); item.setSelected(choice.assigned());
item.addActionListener(a -> actions.setClientChannelGroup(client, g)); item.addActionListener(a -> actions.setClientChannelGroup(client, g));
menu.add(item); menu.add(item);
} }

View File

@@ -22,9 +22,6 @@ import java.awt.Dimension;
*/ */
final class ReasonDialog { final class ReasonDialog {
/** The longest reason the protocol carries with a kick. */
static final int KICK_REASON_LIMIT = 40;
private ReasonDialog() { private ReasonDialog() {
} }

View File

@@ -4,6 +4,7 @@ import com.formdev.flatlaf.util.ScaledEmptyBorder;
import com.formdev.flatlaf.util.UIScale; import com.formdev.flatlaf.util.UIScale;
import com.ts3client.net.ClientEntry; import com.ts3client.net.ClientEntry;
import com.ts3client.net.Group; import com.ts3client.net.Group;
import com.ts3client.net.GroupChoice;
import com.ts3client.net.TeamspeakConnection; import com.ts3client.net.TeamspeakConnection;
import javax.swing.BoxLayout; import javax.swing.BoxLayout;
@@ -19,7 +20,6 @@ import javax.swing.SwingConstants;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.Dimension; import java.awt.Dimension;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@@ -82,19 +82,15 @@ final class ServerGroupsDialog extends JDialog {
} }
header.setText(client.nickname); header.setText(client.nickname);
List<Group> groups = conn.getModel().allServerGroups();
list.removeAll(); list.removeAll();
for (Group g : groups) { for (GroupChoice choice : conn.getModel().serverGroupChoices(client)) {
boolean assigned = client.inServerGroup(g.id); Group g = choice.group();
boolean allowed = conn.getModel().canToggleGroup(g, assigned);
ImageIcon icon = groupIcons.iconOf(g); ImageIcon icon = groupIcons.iconOf(g);
JCheckBox box = new JCheckBox(g.name, allowed ? icon : grayscale(icon)); JCheckBox box = new JCheckBox(g.name, choice.allowed() ? icon : grayscale(icon));
box.setSelected(assigned); box.setSelected(choice.assigned());
box.setEnabled(allowed); box.setEnabled(choice.allowed());
box.setAlignmentX(0f); box.setAlignmentX(0f);
if (allowed) { if (choice.allowed()) {
box.addActionListener(a -> actions.setClientServerGroup(client, g, box.isSelected())); box.addActionListener(a -> actions.setClientServerGroup(client, g, box.isSelected()));
} }
list.add(box); list.add(box);

View File

@@ -155,14 +155,14 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions {
private String kickReason(String title, ClientEntry client) { private String kickReason(String title, ClientEntry client) {
if (!conn.isConnected()) return null; if (!conn.isConnected()) return null;
return ReasonDialog.prompt(host, title, "Reason for kicking " + client.nickname + ":", return ReasonDialog.prompt(host, title, "Reason for kicking " + client.nickname + ":",
ReasonDialog.KICK_REASON_LIMIT); TeamspeakConnection.KICK_REASON_LIMIT);
} }
@Override @Override
public void banClient(ClientEntry client) { public void banClient(ClientEntry client) {
if (!conn.isConnected()) return; if (!conn.isConnected()) return;
BanDialog dialog = new BanDialog(host, client.nickname, BanDialog dialog = new BanDialog(host, client.nickname,
conn.getModel().selfPermissionValue("i_client_ban_max_bantime")); conn.getModel().maxBanSeconds());
dialog.setVisible(true); dialog.setVisible(true);
if (dialog.isConfirmed()) conn.banClient(client.id, dialog.getSeconds(), dialog.getReason()); if (dialog.isConfirmed()) conn.banClient(client.id, dialog.getSeconds(), dialog.getReason());
} }