From 6e4740ae49ba0f93b6814a75adc6b9aa862fcd98 Mon Sep 17 00:00:00 2001 From: infi Date: Tue, 4 Aug 2026 13:01:48 +0200 Subject: [PATCH] feat: show users currently in voice --- .../screens/chat/drawer/ChannelSideDrawer.kt | 274 +++++++++++++----- app/src/main/res/values/strings.xml | 1 + 2 files changed, 202 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/chat/stoat/composables/screens/chat/drawer/ChannelSideDrawer.kt b/app/src/main/java/chat/stoat/composables/screens/chat/drawer/ChannelSideDrawer.kt index 7920b437..3229f500 100644 --- a/app/src/main/java/chat/stoat/composables/screens/chat/drawer/ChannelSideDrawer.kt +++ b/app/src/main/java/chat/stoat/composables/screens/chat/drawer/ChannelSideDrawer.kt @@ -1,5 +1,6 @@ package chat.stoat.composables.screens.chat.drawer +import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Spring @@ -81,6 +82,7 @@ import chat.stoat.api.internals.CategorisedChannelList import chat.stoat.api.internals.ChannelUtils import chat.stoat.api.internals.DirectMessages import chat.stoat.api.internals.FriendRequests +import chat.stoat.api.routes.user.addUserIfUnknown import chat.stoat.api.settings.GeoStateProvider import chat.stoat.api.settings.NotificationSettingsProvider import chat.stoat.api.settings.SyncedSettings @@ -97,10 +99,16 @@ import chat.stoat.core.model.schemas.ChannelType import chat.stoat.core.model.schemas.ServerFlags import chat.stoat.core.model.schemas.User import chat.stoat.core.model.schemas.has +import chat.stoat.core.model.util.UserVoiceState import chat.stoat.screens.chat.ChatRouterDestination import chat.stoat.screens.chat.LocalIsConnected import chat.stoat.sheets.ChannelContextSheet +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import logcat.LogPriority +import logcat.asLog +import logcat.logcat @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable @@ -824,6 +832,7 @@ fun ColumnScope.ServerChannelListRenderer( channelOrCat.channel.id!!, serverId ), + showVoiceParticipants = true, onOpenChannelContextSheet = onOpenChannelContextSheet ) } @@ -863,6 +872,7 @@ fun ChannelItem( hasUnread: Boolean = false, isMuted: Boolean = false, appendServerName: Boolean = false, + showVoiceParticipants: Boolean = false, onDestinationChanged: (ChatRouterDestination) -> Unit, onOpenChannelContextSheet: (String) -> Unit ) { @@ -877,86 +887,204 @@ fun ChannelItem( } } ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start), - modifier = Modifier - .padding(start = 8.dp, end = 8.dp) - .clip( - CircleShape - ) - .combinedClickable( - onLongClickLabel = stringResource(R.string.channel_context_sheet_open), - onLongClick = { - channel.id?.let { chId -> - onOpenChannelContextSheet(chId) + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start), + modifier = Modifier + .padding(start = 8.dp, end = 8.dp) + .clip( + CircleShape + ) + .combinedClickable( + onLongClickLabel = stringResource(R.string.channel_context_sheet_open), + onLongClick = { + channel.id?.let { chId -> + onOpenChannelContextSheet(chId) + } + }, + onClick = { + channel.id?.let { chId -> + onDestinationChanged(ChatRouterDestination.Channel(chId)) + } } + ) + .then( + if (isCurrent) { + Modifier.background(MaterialTheme.colorScheme.secondaryContainer) + } else { + Modifier + } + ) + .then( + if (isMuted) { + Modifier.alpha(0.5f) + } else { + Modifier + } + ) + .padding(16.dp) + .fillMaxWidth()) { + when (iconType) { + is ChannelItemIconType.Channel -> { + when { + GeoStateProvider.geoState?.isAgeRestrictedGeo == true && + channel.nsfw == true -> { + Icon( + painter = painterResource(R.drawable.ic_grid_3x3_off_24dp), + contentDescription = stringResource(R.string.geogate_channel_icon_alt), + ) + } + + channel.channelType == ChannelType.TextChannel && channel.voice != null -> { + ChannelIcon(channel = channel) + } + + else -> ChannelIcon(iconType.type) + } + } + + is ChannelItemIconType.Painter -> { + Icon(painter = iconType.painter, contentDescription = null) + } + } + Text( + text = (ChannelUtils.resolveName(channel) ?: stringResource(R.string.unknown)) + + if (appendServerName && channel.server != null) { + " (${StoatAPI.serverCache[channel.server]?.name ?: stringResource(R.string.unknown)})" + } else { + "" }, - onClick = { - channel.id?.let { chId -> - onDestinationChanged(ChatRouterDestination.Channel(chId)) - } - } + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) - .then( - if (isCurrent) { - Modifier.background(MaterialTheme.colorScheme.secondaryContainer) - } else { + if (hasUnread && !isCurrent) { + Spacer(Modifier.weight(1f)) + Box( Modifier - } - ) - .then( - if (isMuted) { - Modifier.alpha(0.5f) - } else { - Modifier - } - ) - .padding(16.dp) - .fillMaxWidth()) { - when (iconType) { - is ChannelItemIconType.Channel -> { - when { - GeoStateProvider.geoState?.isAgeRestrictedGeo == true && - channel.nsfw == true -> { - Icon( - painter = painterResource(R.drawable.ic_grid_3x3_off_24dp), - contentDescription = stringResource(R.string.geogate_channel_icon_alt), - ) - } - - channel.channelType == ChannelType.TextChannel && channel.voice != null -> { - ChannelIcon(channel = channel) - } - - else -> ChannelIcon(iconType.type) - } - } - - is ChannelItemIconType.Painter -> { - Icon(painter = iconType.painter, contentDescription = null) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .requiredSize(8.dp) + ) } } - Text( - text = (ChannelUtils.resolveName(channel) ?: stringResource(R.string.unknown)) - + if (appendServerName && channel.server != null) { - " (${StoatAPI.serverCache[channel.server]?.name ?: stringResource(R.string.unknown)})" - } else { - "" - }, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis + + if (showVoiceParticipants && + channel.channelType == ChannelType.TextChannel && + channel.voice != null + ) { + VoiceChannelParticipantPreview( + channel = channel, + modifier = if (isMuted) Modifier.alpha(0.5f) else Modifier + ) + } + } + } +} + +private const val MAX_VISIBLE_VOICE_PARTICIPANTS = 5 + +@Composable +private fun VoiceChannelParticipantPreview( + channel: Channel, + modifier: Modifier = Modifier, +) { + val channelId = channel.id ?: return + val participants = StoatAPI.voiceStateCache[channelId]?.participants.orEmpty() + val participantIds = participants.map { it.id }.distinct() + + LaunchedEffect(participantIds) { + supervisorScope { + participantIds + .filter { StoatAPI.userCache[it] == null } + .forEach { userId -> + launch { + try { + addUserIfUnknown(userId) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logcat(LogPriority.ERROR) { + "Failed to fetch voice participant $userId\n" + + e.asLog() + } + } + } + } + } + } + + AnimatedVisibility( + visible = participants.isNotEmpty(), + modifier = modifier + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(start = 56.dp, top = 4.dp, end = 24.dp, bottom = 8.dp) + ) { + participants.take(MAX_VISIBLE_VOICE_PARTICIPANTS).forEach { participant -> + VoiceChannelParticipantRow( + state = participant, + channel = channel + ) + } + + val hiddenParticipantCount = + (participants.size - MAX_VISIBLE_VOICE_PARTICIPANTS).coerceAtLeast(0) + if (hiddenParticipantCount > 0) { + Text( + text = stringResource( + R.string.channel_voice_participants_more, + hiddenParticipantCount + ), + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(alpha = 0.7f), + modifier = Modifier.padding(start = 28.dp) + ) + } + } + } +} + +@Composable +private fun VoiceChannelParticipantRow( + state: UserVoiceState, + channel: Channel, +) { + val user = StoatAPI.userCache[state.id] + val displayName = channel.server + ?.let { StoatAPI.members.getMember(it, state.id)?.nickname } + ?: user?.let(User::resolveDefaultName) + ?: stringResource(R.string.unknown) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + UserAvatar( + username = displayName, + userId = state.id, + avatar = user?.avatar, + size = 20.dp + ) + Text( + text = displayName, + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + if (state.screensharing) { + Icon( + painter = painterResource(R.drawable.ic_screen_share_24dp), + contentDescription = stringResource(R.string.voice_screen_sharing), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) ) - if (hasUnread && !isCurrent) { - Spacer(Modifier.weight(1f)) - Box( - Modifier - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) - .requiredSize(8.dp) - ) - } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a3dc543f..f2cc0af2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -701,6 +701,7 @@ Muted Deafened Sharing their screen + and %1$d more Camera on Disconnect