Find a client in the channel tree from their sheet on Android

Offered, as on desktop, when the sheet was opened from outside the tree
(a name in the chat): expands the client's channels, scrolls to them and
flashes their row amber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 09:13:49 +00:00
parent f80f619a65
commit 66ef62913a
5 changed files with 80 additions and 7 deletions

View File

@@ -148,6 +148,8 @@ class ServerController internal constructor(
fun toggleCollapsed(channelId: Int) = fun toggleCollapsed(channelId: Int) =
_collapsed.update { if (channelId in it) it - channelId else it + channelId } _collapsed.update { if (channelId in it) it - channelId else it + channelId }
fun expand(channelIds: Collection<Int>) = _collapsed.update { it - channelIds.toSet() }
val canMoveClients: Boolean get() = session.canMoveClients() val canMoveClients: Boolean get() = session.canMoveClients()
fun moveClient(clientId: Int, channelId: Int) { fun moveClient(clientId: Int, channelId: Int) {

View File

@@ -30,6 +30,18 @@ data class ServerTree(
return found return found
} }
/** The channels from the root down to the one [clientId] is in; null when they are not in the tree. */
fun pathTo(clientId: Int): List<Int>? {
fun walk(items: List<ChannelItem>): List<Int>? {
for (c in items) {
if (c.clients.any { it.id == clientId }) return listOf(c.id)
walk(c.children)?.let { return listOf(c.id) + it }
}
return null
}
return walk(channels)
}
private fun find(items: List<ChannelItem>, test: (ChannelItem) -> Boolean): ChannelItem? { private fun find(items: List<ChannelItem>, test: (ChannelItem) -> Boolean): ChannelItem? {
for (c in items) { for (c in items) {
if (test(c)) return c if (test(c)) return c

View File

@@ -1,5 +1,8 @@
package com.ts3client.android.ui package com.ts3client.android.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -16,6 +19,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -24,7 +28,12 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
@@ -39,6 +48,8 @@ import com.ts3client.android.ServerTree
import com.ts3client.contacts.ContactType import com.ts3client.contacts.ContactType
import com.ts3client.gfx.IconKeys import com.ts3client.gfx.IconKeys
import com.ts3client.net.ClientStatus import com.ts3client.net.ClientStatus
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
/** One line of the tree as the list shows it. */ /** One line of the tree as the list shows it. */
private sealed interface TreeRow { private sealed interface TreeRow {
@@ -79,9 +90,30 @@ fun ChannelTree(
onClient: (ClientItem) -> Unit, onClient: (ClientItem) -> Unit,
onServer: () -> Unit, onServer: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
/** A client to scroll to and flash, once their channels are expanded. */
reveal: Int? = null,
onRevealed: () -> Unit = {},
) { ) {
val rows = remember(tree, collapsed, currentChannel) { flatten(tree, collapsed, currentChannel) } val rows = remember(tree, collapsed, currentChannel) { flatten(tree, collapsed, currentChannel) }
LazyColumn(modifier.fillMaxSize(), contentPadding = PaddingValues(vertical = 4.dp)) { val listState = rememberLazyListState()
var flashing by remember { mutableStateOf<Int?>(null) }
LaunchedEffect(reveal, rows) {
val id = reveal ?: return@LaunchedEffect
val index = rows.indexOfFirst { it is TreeRow.Client && it.client.id == id }
if (index < 0) return@LaunchedEffect
// Index 0 is the server's row. The client ends up in the middle of the list.
val middle = snapshotFlow { listState.layoutInfo.viewportSize.height }.first { it > 0 } / 2
listState.animateScrollToItem(index + 1, -middle)
flashing = id
onRevealed()
}
LaunchedEffect(flashing) {
if (flashing != null) {
delay(1500)
flashing = null
}
}
LazyColumn(modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(vertical = 4.dp)) {
item(key = "server") { item(key = "server") {
RowLayout(depth = 0, onClick = onServer) { RowLayout(depth = 0, onClick = onServer) {
PackIcon("SERVER_GREEN", null, size = ICON) PackIcon("SERVER_GREEN", null, size = ICON)
@@ -93,7 +125,7 @@ fun ChannelTree(
items(rows, key = { it.key }) { row -> items(rows, key = { it.key }) { row ->
when (row) { when (row) {
is TreeRow.Channel -> ChannelRow(row, onToggle, onChannel) is TreeRow.Channel -> ChannelRow(row, onToggle, onChannel)
is TreeRow.Client -> ClientRow(row, tree.countryFlags, onClient) is TreeRow.Client -> ClientRow(row, tree.countryFlags, row.client.id == flashing, onClient)
} }
} }
} }
@@ -102,12 +134,20 @@ fun ChannelTree(
private val ICON = 16.dp private val ICON = 16.dp
private val INDENT = 14.dp private val INDENT = 14.dp
private val ROW = 26.dp private val ROW = 26.dp
/** Amber, see-through so the row's text keeps its own colour and contrast. */
private val FLASH = Color(0x80FFB300)
@Composable @Composable
private fun RowLayout(depth: Int, onClick: (() -> Unit)?, content: @Composable RowScope.() -> Unit) { private fun RowLayout(
depth: Int,
onClick: (() -> Unit)?,
background: Color = Color.Transparent,
content: @Composable RowScope.() -> Unit,
) {
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.background(background)
.height(ROW) .height(ROW)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(start = 4.dp + INDENT * depth, end = 8.dp), .padding(start = 4.dp + INDENT * depth, end = 8.dp),
@@ -170,9 +210,14 @@ private fun SpacerLabel(align: Char, caption: String, modifier: Modifier) {
} }
@Composable @Composable
private fun ClientRow(row: TreeRow.Client, countryFlags: Boolean, onClient: (ClientItem) -> Unit) { private fun ClientRow(row: TreeRow.Client, countryFlags: Boolean, flashing: Boolean, onClient: (ClientItem) -> Unit) {
val client = row.client val client = row.client
RowLayout(row.depth, onClick = { onClient(client) }) { val background by animateColorAsState(
if (flashing) FLASH else FLASH.copy(alpha = 0f),
tween(if (flashing) 150 else 600),
label = "flash",
)
RowLayout(row.depth, onClick = { onClient(client) }, background = background) {
Spacer(Modifier.width(ICON + 4.dp)) Spacer(Modifier.width(ICON + 4.dp))
StatusIcon(client.status) StatusIcon(client.status)
Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(4.dp)) {

View File

@@ -113,6 +113,9 @@ fun ServerScreen(
var chatKey by rememberSaveable(stateSaver = ChatKeySaver) { mutableStateOf<ChatKey>(ChatKey.Channel) } var chatKey by rememberSaveable(stateSaver = ChatKeySaver) { mutableStateOf<ChatKey>(ChatKey.Channel) }
val conversation = chats[chatKey] ?: chats.channel val conversation = chats[chatKey] ?: chats.channel
var clientSheet by remember { mutableStateOf<Int?>(null) } var clientSheet by remember { mutableStateOf<Int?>(null) }
var clientSheetFromTree by remember { mutableStateOf(false) }
/** A client to show in the tree. */
var reveal by remember { mutableStateOf<Int?>(null) }
var channelSheet by remember { mutableStateOf<Int?>(null) } var channelSheet by remember { mutableStateOf<Int?>(null) }
var serverSheet by remember { mutableStateOf(false) } var serverSheet by remember { mutableStateOf(false) }
/** The client to move, while a channel for them is being picked. */ /** The client to move, while a channel for them is being picked. */
@@ -138,7 +141,7 @@ fun ServerScreen(
val ref = TsLink.parse(href) val ref = TsLink.parse(href)
when { when {
ref == null -> runCatching { uriHandler.openUri(href) } ref == null -> runCatching { uriHandler.openUri(href) }
ref.client -> clientSheet = ref.id ref.client -> { clientSheet = ref.id; clientSheetFromTree = false }
else -> channelSheet = ref.id else -> channelSheet = ref.id
} }
} }
@@ -196,9 +199,11 @@ fun ServerScreen(
else -> channelSheet = channel.id else -> channelSheet = channel.id
} }
}, },
onClient = { clientSheet = it.id }, onClient = { clientSheet = it.id; clientSheetFromTree = true },
onServer = { serverSheet = true }, onServer = { serverSheet = true },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
reveal = reveal,
onRevealed = { reveal = null },
) )
} else { } else {
ChatPane( ChatPane(
@@ -243,6 +248,12 @@ fun ServerScreen(
onMoveToOwnChannel = if (server.canMoveClients && tree.channel(state.channelId)?.clients?.none { it.id == id } == true) { onMoveToOwnChannel = if (server.canMoveClients && tree.channel(state.channelId)?.clients?.none { it.id == id } == true) {
{ server.moveClient(id, state.channelId); clientSheet = null } { server.moveClient(id, state.channelId); clientSheet = null }
} else null, } else null,
onFindInTree = if (clientSheetFromTree) null else ({
clientSheet = null
tree.pathTo(id)?.let { server.expand(it) }
showChat = false
reveal = id
}),
) )
} }
} }

View File

@@ -57,6 +57,8 @@ fun ClientSheet(
onMove: (() -> Unit)?, onMove: (() -> Unit)?,
/** Null when the client cannot be moved, or is in our channel already. */ /** Null when the client cannot be moved, or is in our channel already. */
onMoveToOwnChannel: (() -> Unit)?, onMoveToOwnChannel: (() -> Unit)?,
/** Null when the sheet was opened from the tree. */
onFindInTree: (() -> Unit)?,
) { ) {
var poking by remember { mutableStateOf(false) } var poking by remember { mutableStateOf(false) }
ModalBottomSheet(onDismissRequest = onDismiss) { ModalBottomSheet(onDismissRequest = onDismiss) {
@@ -82,6 +84,7 @@ fun ClientSheet(
}) })
} }
details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) } details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) }
onFindInTree?.let { Action("CHANNEL_SWITCH", "Find client in channel tree", onClick = it) }
if (client.self) { if (client.self) {
Action("AWAY", if (away) "Back" else "Away") { onAway(!away) } Action("AWAY", if (away) "Back" else "Away") { onAway(!away) }
} else { } else {