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.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<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. */
suspend fun setAvatar(data: ByteArray): String? {
if (!session.isConnected) return "Not connected"

View File

@@ -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<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.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())

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 channelSheet by remember { mutableStateOf<Int?>(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<Int?>(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)
}
}
}

View File

@@ -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) },

View File

@@ -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) },