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:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
225
android/app/src/main/java/com/ts3client/android/ui/Moderation.kt
Normal file
225
android/app/src/main/java/com/ts3client/android/ui/Moderation.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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) },
|
||||
|
||||
Reference in New Issue
Block a user