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) =
_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()
fun moveClient(clientId: Int, channelId: Int) {

View File

@@ -30,6 +30,18 @@ data class ServerTree(
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? {
for (c in items) {
if (test(c)) return c

View File

@@ -1,5 +1,8 @@
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.layout.Arrangement
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.HorizontalDivider
@@ -24,7 +28,12 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
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.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
@@ -39,6 +48,8 @@ import com.ts3client.android.ServerTree
import com.ts3client.contacts.ContactType
import com.ts3client.gfx.IconKeys
import com.ts3client.net.ClientStatus
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
/** One line of the tree as the list shows it. */
private sealed interface TreeRow {
@@ -79,9 +90,30 @@ fun ChannelTree(
onClient: (ClientItem) -> Unit,
onServer: () -> Unit,
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) }
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") {
RowLayout(depth = 0, onClick = onServer) {
PackIcon("SERVER_GREEN", null, size = ICON)
@@ -93,7 +125,7 @@ fun ChannelTree(
items(rows, key = { it.key }) { row ->
when (row) {
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 INDENT = 14.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
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(
Modifier
.fillMaxWidth()
.background(background)
.height(ROW)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(start = 4.dp + INDENT * depth, end = 8.dp),
@@ -170,9 +210,14 @@ private fun SpacerLabel(align: Char, caption: String, modifier: Modifier) {
}
@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
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))
StatusIcon(client.status)
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) }
val conversation = chats[chatKey] ?: chats.channel
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 serverSheet by remember { mutableStateOf(false) }
/** The client to move, while a channel for them is being picked. */
@@ -138,7 +141,7 @@ fun ServerScreen(
val ref = TsLink.parse(href)
when {
ref == null -> runCatching { uriHandler.openUri(href) }
ref.client -> clientSheet = ref.id
ref.client -> { clientSheet = ref.id; clientSheetFromTree = false }
else -> channelSheet = ref.id
}
}
@@ -196,9 +199,11 @@ fun ServerScreen(
else -> channelSheet = channel.id
}
},
onClient = { clientSheet = it.id },
onClient = { clientSheet = it.id; clientSheetFromTree = true },
onServer = { serverSheet = true },
modifier = Modifier.weight(1f),
reveal = reveal,
onRevealed = { reveal = null },
)
} else {
ChatPane(
@@ -243,6 +248,12 @@ fun ServerScreen(
onMoveToOwnChannel = if (server.canMoveClients && tree.channel(state.channelId)?.clients?.none { it.id == id } == true) {
{ server.moveClient(id, state.channelId); clientSheet = 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)?,
/** Null when the client cannot be moved, or is in our channel already. */
onMoveToOwnChannel: (() -> Unit)?,
/** Null when the sheet was opened from the tree. */
onFindInTree: (() -> Unit)?,
) {
var poking by remember { mutableStateOf(false) }
ModalBottomSheet(onDismissRequest = onDismiss) {
@@ -82,6 +84,7 @@ fun ClientSheet(
})
}
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) {
Action("AWAY", if (away) "Back" else "Away") { onAway(!away) }
} else {