diff --git a/android/app/src/main/java/com/ts3client/android/ServerController.kt b/android/app/src/main/java/com/ts3client/android/ServerController.kt index 20670c7..3f8c2ef 100644 --- a/android/app/src/main/java/com/ts3client/android/ServerController.kt +++ b/android/app/src/main/java/com/ts3client/android/ServerController.kt @@ -3,6 +3,9 @@ package com.ts3client.android import com.ts3client.contacts.ContactType import com.ts3client.net.ChannelNode 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.session.ServerSession import com.ts3client.text.BBCode @@ -168,6 +171,71 @@ class ServerController internal constructor( 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 = + conn.model.getClient(clientId)?.let(conn.model::serverGroupChoices).orEmpty() + + fun channelGroupChoices(clientId: Int): List = + 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> = 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): String? = + suspendCoroutine { done -> conn.deleteBans(banIds) { done.resume(it) } } + + fun canDeleteBans(bans: Collection): 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. */ suspend fun setAvatar(data: ByteArray): String? { if (!session.isConnected) return "Not connected" diff --git a/android/app/src/main/java/com/ts3client/android/ServerTree.kt b/android/app/src/main/java/com/ts3client/android/ServerTree.kt index 51b82b1..e7f7078 100644 --- a/android/app/src/main/java/com/ts3client/android/ServerTree.kt +++ b/android/app/src/main/java/com/ts3client/android/ServerTree.kt @@ -131,6 +131,8 @@ data class ServerTree( .map { it.iconId }.filter { it != 0L }, avatarFlag = c.avatarFlag.orEmpty(), country = c.country.orEmpty(), + serverGroupIds = c.serverGroupIds.toList(), + channelGroupId = c.channelGroupId, ) } } @@ -178,4 +180,6 @@ data class ClientItem( val avatarFlag: String, /** The code of the country the server places the client in; empty when it cannot tell. */ val country: String, + val serverGroupIds: List, + val channelGroupId: Int, ) diff --git a/android/app/src/main/java/com/ts3client/android/SessionController.kt b/android/app/src/main/java/com/ts3client/android/SessionController.kt index dc1c8ad..f880e22 100644 --- a/android/app/src/main/java/com/ts3client/android/SessionController.kt +++ b/android/app/src/main/java/com/ts3client/android/SessionController.kt @@ -10,6 +10,7 @@ import android.os.Looper import com.ts3client.android.audio.AndroidAudioBackend import com.ts3client.android.audio.AudioRoute import com.ts3client.audio.AudioDevice +import com.ts3client.config.BanReasonPresets import com.ts3client.config.Bookmark import com.ts3client.config.Bookmarks 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 notifier = MessageNotifier(context) 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) private val main = Handler(Looper.getMainLooper()) diff --git a/android/app/src/main/java/com/ts3client/android/ui/BanListScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/BanListScreen.kt new file mode 100644 index 0000000..7e3763b --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/BanListScreen.kt @@ -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?>(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(null) } + var edit by remember { mutableStateOf(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() + }) + } + } +} diff --git a/android/app/src/main/java/com/ts3client/android/ui/Moderation.kt b/android/app/src/main/java/com/ts3client/android/ui/Moderation.kt new file mode 100644 index 0000000..a9bc625 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/Moderation.kt @@ -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, + 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(null) } + var banning by remember { mutableStateOf(false) } + var groups by remember { mutableStateOf(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) + } + } +} diff --git a/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt index cccc3b6..696abb9 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt @@ -127,6 +127,7 @@ fun ServerScreen( var reveal by remember { mutableStateOf(null) } var channelSheet by remember { mutableStateOf(null) } var serverSheet by remember { mutableStateOf(false) } + var banList by rememberSaveable { mutableStateOf(false) } /** The client to move, while a channel for them is being picked. */ var moving by remember { mutableStateOf(null) } 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(moving != null && !showChat) { moving = null } BackHandler(find.open) { find.open = false } @@ -337,6 +343,7 @@ fun ServerScreen( showChat = false reveal = TreeRef(false, id) }), + moderation = { ClientModeration(server, client) { clientSheet = null } }, ) } } @@ -356,7 +363,8 @@ fun ServerScreen( } if (serverSheet) { 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) } } } diff --git a/android/app/src/main/java/com/ts3client/android/ui/SettingsWidgets.kt b/android/app/src/main/java/com/ts3client/android/ui/SettingsWidgets.kt index eab07b1..6bb9547 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/SettingsWidgets.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/SettingsWidgets.kt @@ -25,6 +25,8 @@ import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Slider +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -49,10 +51,12 @@ internal fun SubScreen( title: String, onBack: () -> Unit, actions: @Composable RowScope.() -> Unit = {}, + snackbar: SnackbarHostState? = null, content: @Composable (PaddingValues) -> Unit, ) { BackHandler(onBack = onBack) Scaffold( + snackbarHost = { snackbar?.let { SnackbarHost(it) } }, topBar = { TopAppBar( title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, diff --git a/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt b/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt index 4c60062..34b327f 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt @@ -59,6 +59,8 @@ fun ClientSheet( onMoveToOwnChannel: (() -> Unit)?, /** Null when the sheet was opened from the tree. */ onFindInTree: (() -> Unit)?, + /** Kicks, bans and groups, as far as we may use them. */ + moderation: @Composable () -> Unit = {}, ) { var poking by remember { mutableStateOf(false) } ModalBottomSheet(onDismissRequest = onDismiss) { @@ -87,11 +89,13 @@ fun ClientSheet( onFindInTree?.let { Action("CHANNEL_SWITCH", "Find client in channel tree", onClick = it) } if (client.self) { Action("AWAY", if (away) "Back" else "Away") { onAway(!away) } + moderation() } else { Action("PLAYER_CHAT", "Send message", onClick = onMessage) Action("POKE", "Poke") { poking = true } onMove?.let { Action("CHANNEL_SWITCH", "Move to channel…", onClick = it) } onMoveToOwnChannel?.let { Action("MOVE_CLIENT_TO_OWN_CHANNEL", "Move to own channel", onClick = it) } + moderation() if (client.contactType == null) { Row { 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. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ServerSheet(details: Details, onLink: (String) -> Unit, onDismiss: () -> Unit) { +fun ServerSheet(details: Details, onLink: (String) -> Unit, onDismiss: () -> Unit, onBanList: (() -> Unit)?) { ModalBottomSheet(onDismissRequest = onDismiss) { Column(Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 16.dp)) { 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) } DetailsSection(details, onLink) + onBanList?.let { Action("BAN_LIST", "Ban list", onClick = it) } } } } @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( headlineContent = { Text(label) }, leadingContent = { PackIcon(iconKey, null) }, diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java index 85cd3d0..9f4c08e 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java @@ -508,7 +508,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(); + if (self) conn.requestSelfPowers(); conn.sound(self ? byInvoker(e, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, SoundEvent.YOU_SERVERGROUP_ADDED_BY_SERVER) @@ -526,7 +526,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(); + if (self) conn.requestSelfPowers(); conn.sound(self ? byInvoker(e, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, 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()); if (c != null) c.channelGroupId = e.getChannelGroupId(); boolean self = e.getClientId() == conn.getSelfClientId(); - if (self) conn.requestMovePower(); + if (self) conn.requestSelfPowers(); String groupName = conn.getModel().channelGroupName(e.getChannelGroupId()); conn.sound(self ? byInvoker(e, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER, diff --git a/ts3-client/core/src/main/java/com/ts3client/net/GroupChoice.java b/ts3-client/core/src/main/java/com/ts3client/net/GroupChoice.java new file mode 100644 index 0000000..2364ec6 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/GroupChoice.java @@ -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) { +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java index d4f1434..2b9b8ef 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java @@ -1,6 +1,9 @@ package com.ts3client.net; +import com.ts3client.net.ban.BanEntry; + import java.util.ArrayList; +import java.util.Collection; import java.util.Comparator; import java.util.HashMap; import java.util.LinkedHashMap; @@ -32,8 +35,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 selfPermissionValues = new LinkedHashMap<>(); - /** The local client's {@code i_client_move_power}, which the needed permissions leave out; 0 until known. */ - private int selfMovePower; + /** The local client's {@link #SELF_POWERS}, which the needed permissions leave out; absent until known. */ + private final Map selfPowers = new HashMap<>(); private String serverName = "TeamSpeak Server"; /** The {@code virtualserver_*} properties from {@code initserver}, kept up to date by server edits. */ private final Map serverProperties = new HashMap<>(); @@ -49,7 +52,7 @@ public final class ServerModel { permissionIds.clear(); nextPermissionId = FIRST_PERMISSION_ID; selfPermissionValues.clear(); - selfMovePower = 0; + selfPowers.clear(); serverProperties.clear(); defaultChannelGroupId = 0; } @@ -157,16 +160,56 @@ public final class ServerModel { 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 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 powers) { + selfPowers.clear(); + selfPowers.putAll(powers); } - public synchronized int selfMovePower() { - return selfMovePower; + /** One of {@link #SELF_POWERS}; 0 until known, or when the server would not tell. */ + public synchronized int selfPower(String name) { + return selfPowers.getOrDefault(name, 0); } // ---- 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 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 * 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"))); } + /** Every regular server group, with whether {@code client} is in it and whether we may change that. */ + public synchronized List serverGroupChoices(ClientEntry client) { + List 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 channelGroupChoices(ClientEntry client) { + List 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) { if (own == -1) return true; if (needed == -1) return false; diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index 5155ec0..c65b813 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -37,6 +37,7 @@ import java.net.InetSocketAddress; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; import java.util.List; import java.util.Map; 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}. */ 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 clientkick. */ private static final int REASON_KICK_CHANNEL = 4; private static final int REASON_KICK_SERVER = 5; @@ -352,7 +356,7 @@ public final class TeamspeakConnection implements TS3Listener { loadChatLogs(); ui.onConnected(); requestPermissionNames(); - requestMovePower(); + requestSelfPowers(); ui.onStatus("Retrieving channels…"); syncAll(); @@ -730,20 +734,25 @@ public final class TeamspeakConnection implements TS3Listener { } /** - * 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. + * Asks for our own {@link ServerModel#SELF_POWERS}, which {@code notifyclientneededpermissions} + * 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 others. */ - void requestMovePower() { + void requestSelfPowers() { + List rows = new ArrayList<>(); + for (String name : ServerModel.SELF_POWERS) { + rows.add(new SingleCommand("permget", ProtocolRole.CLIENT, new CommandSingleParameter("permsid", name))); + } 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); + Map powers = new HashMap<>(); + for (SingleCommand answer : sock.executeCommand(new MultiCommand("permget", ProtocolRole.CLIENT, rows)).get()) { + Map 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.setSelfMovePower(power); - }, e -> model.setSelfMovePower(0)); + model.setSelfPowers(powers); + }, e -> model.setSelfPowers(Map.of())); } /** Assigns or removes a server group for a client (by database id, as {@code servergroupaddclient} needs). */ diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ban/BanEntry.java b/ts3-client/core/src/main/java/com/ts3client/net/ban/BanEntry.java index 673eaf8..75d6b65 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ban/BanEntry.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ban/BanEntry.java @@ -1,5 +1,7 @@ package com.ts3client.net.ban; +import java.util.ArrayList; +import java.util.List; import java.util.Map; /** 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); } + /** What the ban matches, as the official list sums it up: {@code name=…, ip=…, uid=… (last nickname), mytsid=…}. */ + public String targets() { + List 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. */ public BanRule toRule() { return new BanRule(ip, name, uid, mytsid, durationSeconds, reason); diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ban/BanUnit.java b/ts3-client/core/src/main/java/com/ts3client/net/ban/BanUnit.java new file mode 100644 index 0000000..fcc0981 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/ban/BanUnit.java @@ -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; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java b/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java index 0175d42..facfbb5 100644 --- a/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java +++ b/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java @@ -373,28 +373,23 @@ public final class ServerSession { // ---- permissions ---- - /** - * 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. - */ + /** Whether the server grants us {@code b_client_ban_list}; offered while unknown. */ public boolean canViewBanList() { - if (!conn.isConnected()) return false; - ServerModel model = conn.getModel(); - return !model.hasPermissionNames() || model.selfPermissionValue("b_client_ban_list") > 0; + return permits("b_client_ban_list"); } /** Whether the server lets us upload an avatar ({@code i_client_max_avatar_filesize}); offered while unknown. */ public boolean canSetAvatar() { - if (!conn.isConnected()) return false; - ServerModel model = conn.getModel(); - return !model.hasPermissionNames() || model.selfPermissionValue("i_client_max_avatar_filesize") > 0; + return permits("i_client_max_avatar_filesize"); } /** Whether the server lets us delete other clients' avatars; offered while unknown. */ public boolean canDeleteOtherAvatars() { - if (!conn.isConnected()) return false; - ServerModel model = conn.getModel(); - return !model.hasPermissionNames() || model.selfPermissionValue("b_client_avatar_delete_other") > 0; + return permits("b_client_avatar_delete_other"); + } + + private boolean permits(String permission) { + return conn.isConnected() && conn.getModel().permits(permission); } /** Whether we publish an avatar on this server. */ @@ -404,12 +399,28 @@ public final class ServerSession { 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() { - 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 ---- diff --git a/ts3-client/core/src/test/java/com/ts3client/net/ban/BanUnitTest.java b/ts3-client/core/src/test/java/com/ts3client/net/ban/BanUnitTest.java new file mode 100644 index 0000000..006661c --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/net/ban/BanUnitTest.java @@ -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)); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/BanDurationPicker.java b/ts3-client/swing/src/main/java/com/ts3client/ui/BanDurationPicker.java index 57a5adb..d7bf6c8 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/BanDurationPicker.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/BanDurationPicker.java @@ -1,11 +1,14 @@ package com.ts3client.ui; import com.formdev.flatlaf.util.UIScale; +import com.ts3client.net.ban.BanUnit; import javax.swing.Box; import javax.swing.BoxLayout; +import javax.swing.DefaultListCellRenderer; import javax.swing.JButton; import javax.swing.JComboBox; +import javax.swing.JList; import javax.swing.JPanel; import javax.swing.JSpinner; import javax.swing.SpinnerNumberModel; @@ -18,16 +21,8 @@ import java.awt.Component; */ 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 JComboBox unit = new JComboBox<>(UNIT_NAMES); + private final JComboBox unit = new JComboBox<>(BanUnit.values()); private final JButton max = new JButton("Max"); /** @@ -38,7 +33,14 @@ final class BanDurationPicker extends JPanel { setAlignmentX(Component.LEFT_ALIGNMENT); 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())); max.setToolTipText("Set duration to maximum value"); max.addActionListener(e -> setSeconds(maxSeconds)); @@ -53,25 +55,23 @@ final class BanDurationPicker extends JPanel { } 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. */ long getSeconds() { - if (isPermanent()) return 0; - return ((Number) amount.getValue()).longValue() * UNIT_SECONDS[unit.getSelectedIndex()]; + return unit().toSeconds(((Number) amount.getValue()).longValue()); } /** Shows a length in the largest unit that divides it evenly; 0 is permanent. */ void setSeconds(long seconds) { - if (seconds <= 0) { - unit.setSelectedIndex(PERMANENT); - return; - } - 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)); + BanUnit fit = BanUnit.largestFor(seconds); + unit.setSelectedItem(fit); + if (fit != BanUnit.PERMANENT) amount.setValue((int) Math.min(fit.amountOf(seconds), 999_999_999)); } /** Called back whenever the length changes, however it changes. */ diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/BanListDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/BanListDialog.java index 268d737..f17c459 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/BanListDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/BanListDialog.java @@ -4,7 +4,6 @@ import com.formdev.flatlaf.util.ScaledEmptyBorder; import com.formdev.flatlaf.util.UIScale; import com.ts3client.config.BanReasonPresets; import com.ts3client.config.Settings; -import com.ts3client.net.ServerModel; import com.ts3client.net.TeamspeakConnection; import com.ts3client.net.ban.BanEntry; import com.ts3client.net.ban.BanRule; @@ -422,33 +421,25 @@ final class BanListDialog extends JDialog { // ---- 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) { - ServerModel model = conn.getModel(); - return !model.hasPermissionNames() || model.selfPermissionValue(permission) > 0; + return conn.getModel().permits(permission); } private boolean canDelete(List bans) { - if (can("b_client_ban_delete")) return true; - if (!can("b_client_ban_delete_own")) return false; - String self = conn.getSelfUniqueId(); - return bans.stream().allMatch(b -> b.createdBy(self)); + return conn.getModel().canDeleteBans(bans, conn.getSelfUniqueId()); } private void updateButtons() { List selected = selectedBans(); addButton.setEnabled(can("b_client_ban_create")); 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 ---- private long maxBanSeconds() { - return conn.getModel().selfPermissionValue("i_client_ban_max_bantime"); + return conn.getModel().maxBanSeconds(); } private void addBan() { @@ -558,7 +549,7 @@ final class BanListDialog extends JDialog { String text(BanEntry ban, Column column) { switch (column) { case TARGETS: - return targets(ban); + return ban.targets(); case NAME: return ban.name; case IP: @@ -581,18 +572,6 @@ final class BanListDialog extends JDialog { return ""; } } - - /** The official summary column: {@code name=…, ip=…, uid=… (last nickname), mytsid=…}. */ - private String targets(BanEntry ban) { - List 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) { diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java index 93b71d6..d9e76fc 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java @@ -4,6 +4,7 @@ import com.ts3client.contacts.ContactType; import com.ts3client.net.ChannelNode; import com.ts3client.net.ClientEntry; import com.ts3client.net.Group; +import com.ts3client.net.GroupChoice; import com.ts3client.net.ServerModel; import javax.swing.JCheckBoxMenuItem; @@ -128,12 +129,12 @@ final class ClientMenu { menu.add(dialog); menu.addSeparator(); - for (Group g : model.allServerGroups()) { - boolean assigned = client.inServerGroup(g.id); - if (!model.canToggleGroup(g, assigned)) continue; + for (GroupChoice choice : model.serverGroupChoices(client)) { + if (!choice.allowed()) continue; + Group g = choice.group(); JCheckBoxMenuItem item = new JCheckBoxMenuItem(g.name, groupIcons.iconOf(g)); - item.setSelected(assigned); - item.addActionListener(a -> actions.setClientServerGroup(client, g, !assigned)); + item.setSelected(choice.assigned()); + item.addActionListener(a -> actions.setClientServerGroup(client, g, !choice.assigned())); menu.add(item); } return menu; @@ -144,12 +145,11 @@ final class ClientMenu { JMenu menu = new JMenu("Set Channel Group"); menu.setIcon(Icons.of("PERMISSIONS_CHANNEL_GROUPS")); int defaultGroupId = model.defaultChannelGroupId(); - for (Group g : model.allChannelGroups()) { - if (g.id == defaultGroupId) continue; - boolean assigned = client.channelGroupId == g.id; - if (!model.canToggleGroup(g, assigned)) continue; + for (GroupChoice choice : model.channelGroupChoices(client)) { + Group g = choice.group(); + if (g.id == defaultGroupId || !choice.allowed()) continue; JCheckBoxMenuItem item = new JCheckBoxMenuItem(g.name, groupIcons.iconOf(g)); - item.setSelected(assigned); + item.setSelected(choice.assigned()); item.addActionListener(a -> actions.setClientChannelGroup(client, g)); menu.add(item); } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ReasonDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ReasonDialog.java index fc2dd4a..9ccfda5 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ReasonDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ReasonDialog.java @@ -22,9 +22,6 @@ import java.awt.Dimension; */ final class ReasonDialog { - /** The longest reason the protocol carries with a kick. */ - static final int KICK_REASON_LIMIT = 40; - private ReasonDialog() { } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerGroupsDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerGroupsDialog.java index 33188d8..1d68c05 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerGroupsDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerGroupsDialog.java @@ -4,6 +4,7 @@ import com.formdev.flatlaf.util.ScaledEmptyBorder; import com.formdev.flatlaf.util.UIScale; import com.ts3client.net.ClientEntry; import com.ts3client.net.Group; +import com.ts3client.net.GroupChoice; import com.ts3client.net.TeamspeakConnection; import javax.swing.BoxLayout; @@ -19,7 +20,6 @@ import javax.swing.SwingConstants; import java.awt.BorderLayout; import java.awt.Dimension; import java.util.HashMap; -import java.util.List; import java.util.Map; /** @@ -82,19 +82,15 @@ final class ServerGroupsDialog extends JDialog { } header.setText(client.nickname); - List groups = conn.getModel().allServerGroups(); - list.removeAll(); - for (Group g : groups) { - boolean assigned = client.inServerGroup(g.id); - boolean allowed = conn.getModel().canToggleGroup(g, assigned); - + for (GroupChoice choice : conn.getModel().serverGroupChoices(client)) { + Group g = choice.group(); ImageIcon icon = groupIcons.iconOf(g); - JCheckBox box = new JCheckBox(g.name, allowed ? icon : grayscale(icon)); - box.setSelected(assigned); - box.setEnabled(allowed); + JCheckBox box = new JCheckBox(g.name, choice.allowed() ? icon : grayscale(icon)); + box.setSelected(choice.assigned()); + box.setEnabled(choice.allowed()); box.setAlignmentX(0f); - if (allowed) { + if (choice.allowed()) { box.addActionListener(a -> actions.setClientServerGroup(client, g, box.isSelected())); } list.add(box); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java index d408dc4..82be55e 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java @@ -155,14 +155,14 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions { private String kickReason(String title, ClientEntry client) { if (!conn.isConnected()) return null; return ReasonDialog.prompt(host, title, "Reason for kicking " + client.nickname + ":", - ReasonDialog.KICK_REASON_LIMIT); + TeamspeakConnection.KICK_REASON_LIMIT); } @Override public void banClient(ClientEntry client) { if (!conn.isConnected()) return; BanDialog dialog = new BanDialog(host, client.nickname, - conn.getModel().selfPermissionValue("i_client_ban_max_bantime")); + conn.getModel().maxBanSeconds()); dialog.setVisible(true); if (dialog.isConfirmed()) conn.banClient(client.id, dialog.getSeconds(), dialog.getReason()); }