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) },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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<Integer, Integer> selfPermissionValues = new LinkedHashMap<>();
|
||||
/** The local client's {@code i_client_move_power}, which the needed permissions leave out; 0 until known. */
|
||||
private int selfMovePower;
|
||||
/** The local client's {@link #SELF_POWERS}, which the needed permissions leave out; absent until known. */
|
||||
private final Map<String, Integer> 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<String, String> 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<String> SELF_POWERS = List.of("i_client_move_power",
|
||||
"i_client_kick_from_channel_power", "i_client_kick_from_server_power", "i_client_ban_power");
|
||||
|
||||
/** Takes on the answer to asking for {@link #SELF_POWERS}; those it leaves out count as 0. */
|
||||
public synchronized void setSelfPowers(Map<String, Integer> powers) {
|
||||
selfPowers.clear();
|
||||
selfPowers.putAll(powers);
|
||||
}
|
||||
|
||||
public synchronized int selfMovePower() {
|
||||
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<BanEntry> bans, String selfUid) {
|
||||
if (permits("b_client_ban_delete")) return true;
|
||||
if (!permits("b_client_ban_delete_own")) return false;
|
||||
return bans.stream().allMatch(b -> b.createdBy(selfUid));
|
||||
}
|
||||
|
||||
/** Whether we may rewrite {@code ban}: that is deleting it and creating its replacement. */
|
||||
public synchronized boolean canEditBan(BanEntry ban, String selfUid) {
|
||||
return permits("b_client_ban_create") && canDeleteBans(List.of(ban), selfUid);
|
||||
}
|
||||
|
||||
/** {@code i_client_ban_max_bantime}: the longest ban we may hand out in seconds; 0 or -1 for no limit. */
|
||||
public synchronized long maxBanSeconds() {
|
||||
return selfPermissionValue("i_client_ban_max_bantime");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the local client has enough power to assign {@code group}, or to take it
|
||||
* 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<GroupChoice> serverGroupChoices(ClientEntry client) {
|
||||
List<GroupChoice> out = new ArrayList<>();
|
||||
for (Group g : allServerGroups()) {
|
||||
boolean assigned = client.inServerGroup(g.id);
|
||||
out.add(new GroupChoice(g, assigned, canToggleGroup(g, assigned)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every regular channel group, with whether it is {@code client}'s and whether we may make it so. */
|
||||
public synchronized List<GroupChoice> channelGroupChoices(ClientEntry client) {
|
||||
List<GroupChoice> out = new ArrayList<>();
|
||||
for (Group g : allChannelGroups()) {
|
||||
boolean assigned = client.channelGroupId == g.id;
|
||||
out.add(new GroupChoice(g, assigned, canToggleGroup(g, assigned)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static boolean hasPower(int needed, int own) {
|
||||
if (own == -1) return true;
|
||||
if (needed == -1) return false;
|
||||
|
||||
@@ -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 <b>clientkick</b>. */
|
||||
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<SingleCommand> 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<String, Integer> powers = new HashMap<>();
|
||||
for (SingleCommand answer : sock.executeCommand(new MultiCommand("permget", ProtocolRole.CLIENT, rows)).get()) {
|
||||
Map<String, String> fields = answer.toMap();
|
||||
String name = fields.get("permsid");
|
||||
String value = fields.get("permvalue");
|
||||
if (name != null && value != null) powers.put(name, Integer.parseInt(value));
|
||||
}
|
||||
model.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). */
|
||||
|
||||
@@ -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<String> parts = new ArrayList<>(4);
|
||||
if (!name.isEmpty()) parts.add("name=" + name);
|
||||
if (!ip.isEmpty()) parts.add("ip=" + ip);
|
||||
if (!uid.isEmpty()) parts.add("uid=" + uid + (lastNickname.isEmpty() ? "" : " (" + lastNickname + ")"));
|
||||
if (!mytsid.isEmpty()) parts.add("mytsid=" + mytsid);
|
||||
return String.join(", ", parts);
|
||||
}
|
||||
|
||||
/** The entry's targets and settings as a rule, which is what editing starts from. */
|
||||
public BanRule toRule() {
|
||||
return new BanRule(ip, name, uid, mytsid, durationSeconds, reason);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<String> unit = new JComboBox<>(UNIT_NAMES);
|
||||
private final JComboBox<BanUnit> 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. */
|
||||
|
||||
@@ -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<BanEntry> 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<BanEntry> 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<String> parts = new ArrayList<>(4);
|
||||
if (!ban.name.isEmpty()) parts.add("name=" + ban.name);
|
||||
if (!ban.ip.isEmpty()) parts.add("ip=" + ban.ip);
|
||||
if (!ban.uid.isEmpty()) {
|
||||
parts.add("uid=" + ban.uid + (ban.lastNickname.isEmpty() ? "" : " (" + ban.lastNickname + ")"));
|
||||
}
|
||||
if (!ban.mytsid.isEmpty()) parts.add("mytsid=" + ban.mytsid);
|
||||
return String.join(", ", parts);
|
||||
}
|
||||
}
|
||||
|
||||
private static String date(long epochSeconds) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Group> 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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user