Show server, channel and group icons and client avatars on Android
The tree lines icons up at the right of each row as TeamSpeak does: the server's own icon, a channel's icon, and a client's server group icons then their channel group's. The details list each group with its icon, and the client sheet shows the avatar; an animated GIF plays while it is on screen. TeamSpeak's default group icons come from the icon pack, as on the desktop; a server's own uploads are downloaded by core's repositories and decoded once per server, redrawn as they arrive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,8 @@ class ServerController internal constructor(
|
||||
private val _infoUpdates = MutableStateFlow(0)
|
||||
val infoUpdates: StateFlow<Int> = _infoUpdates
|
||||
|
||||
val images = ServerImages(conn)
|
||||
|
||||
/** A private chat a notification asked to show, until the UI took it up. */
|
||||
private val _chatRequest = MutableStateFlow<ChatKey.Private?>(null)
|
||||
val chatRequest: StateFlow<ChatKey.Private?> = _chatRequest
|
||||
@@ -281,7 +283,7 @@ class ServerController internal constructor(
|
||||
delay(TREE_FRAME_MS)
|
||||
treePending.set(false)
|
||||
if (structureChanged.getAndSet(false)) roots = conn.model.buildTree()
|
||||
_tree.value = ServerTree.of(roots, conn.contacts, app.settings.colorContacts)
|
||||
_tree.value = ServerTree.of(roots, conn.model, conn.contacts, app.settings.colorContacts)
|
||||
val serverName = if (session.isConnected) conn.model.serverName.orEmpty() else ""
|
||||
val channelName = session.currentChannel()?.name.orEmpty()
|
||||
_chats.update {
|
||||
@@ -323,8 +325,13 @@ class ServerController internal constructor(
|
||||
}
|
||||
|
||||
override fun onModelChanged() = scheduleTree(structure = true)
|
||||
override fun onInfoUpdated() = _infoUpdates.update { it + 1 }
|
||||
override fun onIconsUpdated() {}
|
||||
override fun onInfoUpdated() {
|
||||
_infoUpdates.update { it + 1 }
|
||||
// Avatars announce their arrival as info updates.
|
||||
images.loaded()
|
||||
}
|
||||
|
||||
override fun onIconsUpdated() = images.loaded()
|
||||
|
||||
override fun onChat(scope: ChatScope, fromClientId: Int, fromUniqueId: String?, fromName: String, message: String) {
|
||||
append(key(scope, fromClientId), fromName, ChatHtml.message(fromClientId, fromUniqueId, fromName, message))
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.ts3client.android
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageDecoder
|
||||
import android.graphics.drawable.AnimatedImageDrawable
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import com.ts3client.net.TeamspeakConnection
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* One server's icons and its clients' avatars, decoded once. What is still
|
||||
* downloading reads as null until [version] moves on.
|
||||
*/
|
||||
class ServerImages internal constructor(private val conn: TeamspeakConnection) {
|
||||
|
||||
private val icons = ConcurrentHashMap<Long, ImageBitmap>()
|
||||
private val avatars = ConcurrentHashMap<String, AvatarImage>()
|
||||
|
||||
private val _version = MutableStateFlow(0)
|
||||
val version: StateFlow<Int> = _version
|
||||
|
||||
internal fun loaded() = _version.update { it + 1 }
|
||||
|
||||
fun icon(id: Long): ImageBitmap? {
|
||||
if (id == 0L) return null
|
||||
icons[id]?.let { return it }
|
||||
val bitmap = conn.icons.get(id)?.let { decode(it, ICON_PX) } ?: return null
|
||||
icons[id] = bitmap
|
||||
return bitmap
|
||||
}
|
||||
|
||||
fun avatar(uniqueId: String, flag: String): AvatarImage? {
|
||||
if (flag.isEmpty()) return null
|
||||
avatars[flag]?.let { return it }
|
||||
val avatar = conn.avatars.get(uniqueId, flag) ?: return null
|
||||
val image = if (avatar.isAnimated) decodeAnimated(avatar.data()) else decode(avatar.data(), AVATAR_PX)?.let(AvatarImage::Still)
|
||||
if (image != null) avatars[flag] = image
|
||||
return image
|
||||
}
|
||||
|
||||
/** A GIF as it moves; one of a single frame stays still. */
|
||||
private fun decodeAnimated(data: ByteArray): AvatarImage? = try {
|
||||
val drawable = ImageDecoder.decodeDrawable(ImageDecoder.createSource(ByteBuffer.wrap(data))) { decoder, info, _ ->
|
||||
val largest = maxOf(info.size.width, info.size.height)
|
||||
if (largest > AVATAR_PX) {
|
||||
decoder.setTargetSize(info.size.width * AVATAR_PX / largest, info.size.height * AVATAR_PX / largest)
|
||||
}
|
||||
}
|
||||
when (drawable) {
|
||||
is AnimatedImageDrawable -> AvatarImage.Moving(drawable)
|
||||
is BitmapDrawable -> AvatarImage.Still(drawable.bitmap.asImageBitmap())
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
decode(data, AVATAR_PX)?.let(AvatarImage::Still)
|
||||
}
|
||||
|
||||
/** Decoded no larger than needed: avatars may be far bigger than they are ever shown. */
|
||||
private fun decode(data: ByteArray, maxPx: Int): ImageBitmap? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(data, 0, data.size, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
var sample = 1
|
||||
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= maxPx) sample *= 2
|
||||
val options = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||
return BitmapFactory.decodeByteArray(data, 0, data.size, options)?.asImageBitmap()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ICON_PX = 64
|
||||
const val AVATAR_PX = 512
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface AvatarImage {
|
||||
data class Still(val bitmap: ImageBitmap) : AvatarImage
|
||||
|
||||
/** Shared by whatever shows it: one animation, drawn wherever it is on screen. */
|
||||
data class Moving(val drawable: AnimatedImageDrawable) : AvatarImage
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.ts3client.contacts.ContactType
|
||||
import com.ts3client.net.ChannelNode
|
||||
import com.ts3client.net.ClientEntry
|
||||
import com.ts3client.net.ClientStatus
|
||||
import com.ts3client.net.ServerModel
|
||||
import com.ts3client.net.Spacers
|
||||
|
||||
/**
|
||||
@@ -13,7 +14,11 @@ import com.ts3client.net.Spacers
|
||||
* on connection threads, which Compose cannot observe; these never change.
|
||||
*/
|
||||
@Immutable
|
||||
data class ServerTree(val channels: List<ChannelItem> = emptyList()) {
|
||||
data class ServerTree(
|
||||
val channels: List<ChannelItem> = emptyList(),
|
||||
/** The server's own icon; 0 for none. */
|
||||
val serverIconId: Long = 0,
|
||||
) {
|
||||
|
||||
fun channel(id: Int): ChannelItem? = find(channels) { it.id == id }
|
||||
|
||||
@@ -32,24 +37,29 @@ data class ServerTree(val channels: List<ChannelItem> = emptyList()) {
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun of(roots: List<ChannelNode>, contacts: ContactStore, colorContacts: Boolean): ServerTree =
|
||||
ServerTree(roots.map { channel(it, contacts, colorContacts) })
|
||||
fun of(roots: List<ChannelNode>, model: ServerModel, contacts: ContactStore, colorContacts: Boolean): ServerTree =
|
||||
ServerTree(
|
||||
roots.map { channel(it, model, contacts, colorContacts) },
|
||||
model.serverProperty("virtualserver_icon_id").toLongOrNull() ?: 0,
|
||||
)
|
||||
|
||||
private fun channel(c: ChannelNode, contacts: ContactStore, colorContacts: Boolean): ChannelItem {
|
||||
private fun channel(c: ChannelNode, model: ServerModel, contacts: ContactStore, colorContacts: Boolean): ChannelItem {
|
||||
val clients = c.clients
|
||||
val spacer = Spacers.parse(c.name)
|
||||
return ChannelItem(
|
||||
id = c.id,
|
||||
name = c.name ?: "",
|
||||
spacer = Spacers.parse(c.name),
|
||||
spacer = spacer,
|
||||
iconId = if (spacer == null) c.iconId else 0,
|
||||
locked = c.hasPassword,
|
||||
full = c.maxClients >= 0 && clients.size >= c.maxClients,
|
||||
subscribed = c.subscribed,
|
||||
clients = clients.map { client(it, contacts, colorContacts) },
|
||||
children = c.children.map { channel(it, contacts, colorContacts) },
|
||||
clients = clients.map { client(it, model, contacts, colorContacts) },
|
||||
children = c.children.map { channel(it, model, contacts, colorContacts) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun client(c: ClientEntry, contacts: ContactStore, colorContacts: Boolean): ClientItem {
|
||||
private fun client(c: ClientEntry, model: ServerModel, contacts: ContactStore, colorContacts: Boolean): ClientItem {
|
||||
val contact = contacts.find(c.uniqueId)
|
||||
val away = c.away && c.awayMessage.isNotEmpty() && contact?.ignoreAwayMessage != true
|
||||
return ClientItem(
|
||||
@@ -62,6 +72,9 @@ data class ServerTree(val channels: List<ChannelItem> = emptyList()) {
|
||||
self = c.self,
|
||||
contactType = contact?.type,
|
||||
highlight = if (colorContacts) contact?.type ?: ContactType.NEUTRAL else ContactType.NEUTRAL,
|
||||
groupIconIds = (model.serverGroupsOf(c.serverGroupIds) + listOfNotNull(model.channelGroup(c.channelGroupId)))
|
||||
.map { it.iconId }.filter { it != 0L },
|
||||
avatarFlag = c.avatarFlag.orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -76,6 +89,8 @@ data class ChannelItem(
|
||||
val locked: Boolean,
|
||||
val full: Boolean,
|
||||
val subscribed: Boolean,
|
||||
/** 0 for none. */
|
||||
val iconId: Long,
|
||||
val clients: List<ClientItem>,
|
||||
val children: List<ChannelItem>,
|
||||
)
|
||||
@@ -94,4 +109,8 @@ data class ClientItem(
|
||||
val contactType: ContactType?,
|
||||
/** The contact colour the name is drawn in, NEUTRAL when contacts are not coloured. */
|
||||
val highlight: ContactType,
|
||||
/** The icons of the client's server groups, then of its channel group, as TeamSpeak lines them up. */
|
||||
val groupIconIds: List<Long>,
|
||||
/** The MD5 of the client's avatar; empty for none. */
|
||||
val avatarFlag: String,
|
||||
)
|
||||
|
||||
@@ -81,10 +81,12 @@ fun App(vm: SessionController) {
|
||||
}, onBack = { contactsOpen = false })
|
||||
server != null && !connectOpen -> {
|
||||
serverStates.SaveableStateProvider(server.id) {
|
||||
CompositionLocalProvider(LocalServerImages provides server.images) {
|
||||
ServerScreen(vm, server, snackbar, { settingsOpen = true }, openContacts, enableMicrophone,
|
||||
onConnectOther = { connectOpen = true })
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, { openContacts(null) }, connectAsking,
|
||||
onBack = if (current != null) ({ connectOpen = false }) else null)
|
||||
}
|
||||
|
||||
@@ -85,7 +85,9 @@ fun ChannelTree(
|
||||
item(key = "server") {
|
||||
RowLayout(depth = 0, onClick = onServer) {
|
||||
PackIcon("SERVER_GREEN", null, size = ICON)
|
||||
Text(serverName, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(serverName, Modifier.weight(1f), fontWeight = FontWeight.Bold, maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
ServerIcon(tree.serverIconId, size = ICON)
|
||||
}
|
||||
}
|
||||
items(rows, key = { it.key }) { row ->
|
||||
@@ -142,6 +144,7 @@ private fun ChannelRow(row: TreeRow.Channel, onToggle: (ChannelItem) -> Unit, on
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
ServerIcon(channel.iconId, size = ICON)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,6 +175,7 @@ private fun ClientRow(row: TreeRow.Client, onClient: (ClientItem) -> Unit) {
|
||||
RowLayout(row.depth, onClick = { onClient(client) }) {
|
||||
Spacer(Modifier.width(ICON + 4.dp))
|
||||
StatusIcon(client.status)
|
||||
Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
client.name,
|
||||
fontWeight = if (client.self) FontWeight.Bold else FontWeight.Normal,
|
||||
@@ -188,6 +192,8 @@ private fun ClientRow(row: TreeRow.Client, onClient: (ClientItem) -> Unit) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
for (id in client.groupIconIds) ServerIcon(id, size = ICON)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -6,6 +6,8 @@ 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.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -41,7 +43,7 @@ fun DetailsSection(details: Details, onLink: (String) -> Unit, modifier: Modifie
|
||||
Column(modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (details.fields().isNotEmpty()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
for (field in details.fields()) Fact(field.label(), field.value())
|
||||
for (field in details.fields()) Fact(field)
|
||||
}
|
||||
}
|
||||
when {
|
||||
@@ -52,12 +54,26 @@ fun DetailsSection(details: Details, onLink: (String) -> Unit, modifier: Modifie
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun Fact(label: String, value: String) {
|
||||
private fun Fact(field: Details.Field) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(label, Modifier.width(96.dp), style = MaterialTheme.typography.bodySmall,
|
||||
Text(field.label(), Modifier.width(96.dp), style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(value, style = MaterialTheme.typography.bodySmall)
|
||||
if (field.groups().isEmpty()) {
|
||||
Text(field.value(), style = MaterialTheme.typography.bodySmall)
|
||||
} else {
|
||||
// Each group with its icon, as TeamSpeak's info frame lists them.
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
field.groups().forEachIndexed { i, group ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
ServerIcon(group.iconId, size = 14.dp)
|
||||
Text(group.name + if (i < field.groups().size - 1) "," else "",
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.ts3client.android.ui
|
||||
|
||||
import android.graphics.drawable.AnimatedImageDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.ts3client.android.AvatarImage
|
||||
import com.ts3client.android.ServerImages
|
||||
import com.ts3client.net.IconRepository
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** The images of the server on screen. */
|
||||
val LocalServerImages = staticCompositionLocalOf<ServerImages?> { null }
|
||||
|
||||
/**
|
||||
* A group, channel or server icon; nothing while it has none or it is still downloading.
|
||||
* TeamSpeak's default group icons are the pack's, drawn in its style, as on the desktop.
|
||||
*/
|
||||
@Composable
|
||||
fun ServerIcon(id: Long, modifier: Modifier = Modifier, size: Dp = 16.dp) {
|
||||
if (id == 0L) return
|
||||
if (id in 1..IconRepository.MAX_BUNDLED_ID) {
|
||||
val theme = LocalIconTheme.current
|
||||
val active by theme.active.collectAsStateWithLifecycle()
|
||||
val pixels = with(LocalDensity.current) { size.roundToPx() }
|
||||
val rgb = LocalContentColor.current.copy(alpha = 1f).toArgb()
|
||||
val themed = remember(id, pixels, rgb, active) { theme.render(listOf("GROUP_$id"), pixels, rgb)?.asImageBitmap() }
|
||||
if (themed != null) {
|
||||
Image(themed, null, modifier.size(size))
|
||||
return
|
||||
}
|
||||
}
|
||||
val images = LocalServerImages.current ?: return
|
||||
val version by images.version.collectAsStateWithLifecycle()
|
||||
val bitmap = remember(id, version) { images.icon(id) } ?: return
|
||||
Image(bitmap, null, modifier.size(size))
|
||||
}
|
||||
|
||||
/** A client's avatar, moving if it is an animated GIF; nothing while they have none or it is still downloading. */
|
||||
@Composable
|
||||
fun Avatar(uniqueId: String, flag: String, modifier: Modifier = Modifier, size: Dp = 64.dp) {
|
||||
val images = LocalServerImages.current ?: return
|
||||
val version by images.version.collectAsStateWithLifecycle()
|
||||
val image = remember(flag, version) { images.avatar(uniqueId, flag) } ?: return
|
||||
val painter = when (image) {
|
||||
is AvatarImage.Still -> remember(image) { BitmapPainter(image.bitmap) }
|
||||
is AvatarImage.Moving -> rememberMovingPainter(image.drawable)
|
||||
}
|
||||
Image(painter, "Avatar", modifier.size(size).clip(RoundedCornerShape(8.dp)))
|
||||
}
|
||||
|
||||
/** Plays [drawable] while on screen, redrawing at each of its frames. */
|
||||
@Composable
|
||||
private fun rememberMovingPainter(drawable: AnimatedImageDrawable): Painter {
|
||||
val painter = remember(drawable) { MovingPainter(drawable) }
|
||||
DisposableEffect(drawable) {
|
||||
drawable.callback = painter
|
||||
drawable.start()
|
||||
onDispose {
|
||||
drawable.stop()
|
||||
if (drawable.callback === painter) drawable.callback = null
|
||||
}
|
||||
}
|
||||
return painter
|
||||
}
|
||||
|
||||
private class MovingPainter(private val drawable: AnimatedImageDrawable) : Painter(), Drawable.Callback {
|
||||
/** Read while drawing, so that each new frame draws again. */
|
||||
private var frame by mutableIntStateOf(0)
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
override val intrinsicSize = Size(drawable.intrinsicWidth.toFloat(), drawable.intrinsicHeight.toFloat())
|
||||
|
||||
override fun DrawScope.onDraw() {
|
||||
frame
|
||||
drawIntoCanvas { canvas ->
|
||||
drawable.setBounds(0, 0, size.width.roundToInt(), size.height.roundToInt())
|
||||
drawable.draw(canvas.nativeCanvas)
|
||||
}
|
||||
}
|
||||
|
||||
override fun invalidateDrawable(who: Drawable) {
|
||||
frame++
|
||||
}
|
||||
|
||||
override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) {
|
||||
main.postAtTime(what, `when`)
|
||||
}
|
||||
|
||||
override fun unscheduleDrawable(who: Drawable, what: Runnable) {
|
||||
main.removeCallbacks(what)
|
||||
}
|
||||
}
|
||||
@@ -65,11 +65,12 @@ fun ClientSheet(
|
||||
Row(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
StatusIcon(client.status)
|
||||
Column {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(client.name, style = MaterialTheme.typography.titleLarge)
|
||||
if (client.name != client.nickname) Text(client.nickname, style = MaterialTheme.typography.bodyMedium)
|
||||
if (client.awayMessage.isNotEmpty()) Text(client.awayMessage, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Avatar(client.uniqueId, client.avatarFlag)
|
||||
}
|
||||
details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) }
|
||||
if (client.self) {
|
||||
|
||||
Reference in New Issue
Block a user