From ed89e2d5420e6d84bd2ff0fa012d1a96350d6acc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 19:25:36 +0000 Subject: [PATCH] =?UTF-8?q?fix(nests):=20UI=20polish=20=E2=80=94=20Stop=20?= =?UTF-8?q?button,=20unjoinable=20state,=20hand=20queue,=20DST?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit-driven UI fixes that don't fit the perf bucket: * NestActionBar — wire StopBroadcastButton between MicMuteToggle and LeaveStageButton while broadcasting. The doc table at the top of the file already advertised [Mute] [Stop] [Leave the Stage] but the button was defined and never used; speakers had to leave the stage to stop sending audio. * NestActivityContent — render an explicit loading spinner while the kind-30312 resolves and a clear "this room can't be opened" page with a Back button when the event is missing service/endpoint. Replaces the previous black-screen dead end. * HandRaiseQueueSection — wrap filter+sort in remember so a heartbeat doesn't rebuild the queue on every recompose, swap to LazyColumn so a long queue doesn't render every row every frame, and show UsernameDisplay instead of the raw 8-char hex prefix. * CreateNestSheet schedule picker — compute the timezone offset at the picked instant rather than at Instant.now(); the previous variant was off by one hour for rooms scheduled across a DST transition. * NestViewModelFactory — pass OkHttpNestsClient's httpClient via named parameter so the (String) -> OkHttpClient function reference doesn't get bound to the Long callTimeoutMs slot. Fixes the pre-existing :amethyst:compilePlayDebugKotlin failure on the branch base. Adds three strings (loading, unjoinable title/body, back). https://claude.ai/code/session_01DMeCvWyBYVVVPez2hwqCs4 --- .../loggedIn/nests/create/CreateNestSheet.kt | 36 +++-- .../room/activity/NestActivityContent.kt | 151 ++++++++++++++---- .../room/lifecycle/NestViewModelFactory.kt | 6 +- .../nests/room/screen/NestActionBar.kt | 4 + .../nests/room/stage/HandRaiseQueueSection.kt | 92 +++++++---- amethyst/src/main/res/values/strings.xml | 4 + 6 files changed, 213 insertions(+), 80 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt index 62d53c48e..a189a7ce3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt @@ -331,23 +331,25 @@ private fun ScheduleStartPicker( TextButton(onClick = { val dayMillisUtc = datePickerState.selectedDateMillis if (dayMillisUtc != null) { - // DatePicker hands back UTC midnight of the selected - // calendar date. Add the picked hour:minute (local) - // to its seconds, then subtract the local offset to - // land on the correct UTC unix second — same shape - // as ExpirationDatePicker. - val localSeconds = - dayMillisUtc / 1000L + - timePickerState.hour * 3600L + - timePickerState.minute * 60L - val offsetSec = - java.time.ZoneId - .systemDefault() - .rules - .getOffset(java.time.Instant.now()) - .totalSeconds - .toLong() - onChange(localSeconds - offsetSec) + // DatePicker hands back UTC midnight of the + // selected calendar date. Reinterpret that + // calendar date as local + the picked + // hour:minute, then convert to UTC seconds + // using the zone offset AT THE PICKED INSTANT. + // The previous code used `Instant.now()`'s + // offset, which was off by one hour for rooms + // scheduled across a DST transition. + val zone = java.time.ZoneId.systemDefault() + val localDate = + java.time.Instant + .ofEpochMilli(dayMillisUtc) + .atZone(java.time.ZoneOffset.UTC) + .toLocalDate() + val pickedZdt = + localDate + .atTime(timePickerState.hour, timePickerState.minute) + .atZone(zone) + onChange(pickedZdt.toEpochSecond()) } showTime = false }) { Text(stringRes(R.string.nest_create_submit)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt index 8bac896c3..11d37caf6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt @@ -21,6 +21,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -28,6 +37,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.TextAlign +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel @@ -46,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lifecycle.PipBri import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lifecycle.rememberNestViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.screen.NestFullScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.screen.NestPipScreen +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.nestsclient.NestsRoomConfig import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent @@ -71,45 +86,117 @@ internal fun NestActivityContent( ) { val parsedAddress = remember(addressValue) { Address.parse(addressValue) } if (parsedAddress == null) { - // Malformed address — bail. The Activity will receive a Closed - // signal through the VM teardown when the user finishes; until - // then we just render nothing. + // Malformed address — show the unjoinable state so the user + // gets a clear path back instead of staring at a black screen + // while the Activity sits behind a useless extra. + UnjoinableRoomState(onLeave = onLeave) return } LoadAddressableNote(parsedAddress, accountViewModel) { addressableNote -> - addressableNote ?: return@LoadAddressableNote + if (addressableNote == null) { + // Note hasn't resolved yet — relay subscription is in + // flight. Render a centered spinner so the user sees that + // we're working on it (audit Android #6). + LoadingRoomState() + return@LoadAddressableNote + } val event by observeNoteEvent(addressableNote, accountViewModel) - event?.let { - val service = it.service() - val endPoint = it.endpoint() - if (service != null && endPoint != null) { - val viewModel = - rememberNestViewModel( - NestsRoomConfig( - authBaseUrl = service, - endpoint = endPoint, - hostPubkey = it.pubKey, - roomId = it.dTag(), - kind = it.kind, - ), - accountViewModel.account.signer, - ) + val current = event + if (current == null) { + LoadingRoomState() + return@LoadAddressableNote + } + val service = current.service() + val endPoint = current.endpoint() + if (service.isNullOrBlank() || endPoint.isNullOrBlank()) { + UnjoinableRoomState(onLeave = onLeave) + return@LoadAddressableNote + } + val viewModel = + rememberNestViewModel( + NestsRoomConfig( + authBaseUrl = service, + endpoint = endPoint, + hostPubkey = current.pubKey, + roomId = current.dTag(), + kind = current.kind, + ), + accountViewModel.account.signer, + ) - NestActivityBody( - event = it, - roomNote = addressableNote, - viewModel = viewModel, - accountViewModel = accountViewModel, - isInPipMode = isInPipMode, - isPipSupported = isPipSupported, - onMuteState = onMuteState, - onConnectedChange = onConnectedChange, - pipMuteSignal = pipMuteSignal, - onLeave = onLeave, - onMinimize = onMinimize, - ) + NestActivityBody( + event = current, + roomNote = addressableNote, + viewModel = viewModel, + accountViewModel = accountViewModel, + isInPipMode = isInPipMode, + isPipSupported = isPipSupported, + onMuteState = onMuteState, + onConnectedChange = onConnectedChange, + pipMuteSignal = pipMuteSignal, + onLeave = onLeave, + onMinimize = onMinimize, + ) + } +} + +/** + * Centered spinner shown between activity launch and the first + * resolved kind-30312 event. Replaces the previous black screen so + * the user knows the room is being loaded, not stuck. + */ +@Composable +private fun LoadingRoomState() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator() + Text( + text = stringRes(R.string.nest_loading_room), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * Terminal state for rooms whose kind-30312 doesn't carry the + * service/endpoint pair the audio plane needs (or whose address is + * malformed). Renders a clear explanation + a Back button so the + * user can leave instead of force-killing the activity. + */ +@Composable +private fun UnjoinableRoomState(onLeave: () -> Unit) { + Box( + modifier = Modifier.fillMaxSize().padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringRes(R.string.nest_unjoinable_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Text( + text = stringRes(R.string.nest_unjoinable_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + OutlinedButton(onClick = onLeave) { + Text(stringRes(R.string.nest_unjoinable_back)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestViewModelFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestViewModelFactory.kt index 3b662fed4..e5d25b475 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestViewModelFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestViewModelFactory.kt @@ -47,7 +47,11 @@ internal class NestViewModelFactory( @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = NestViewModel( - httpClient = OkHttpNestsClient(Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForVideo), + // Named parameter — `OkHttpNestsClient`'s primary constructor + // declares `callTimeoutMs` first with a default, so the + // function reference must be passed explicitly to the + // `httpClient` slot rather than as the first positional arg. + httpClient = OkHttpNestsClient(httpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForVideo), transport = QuicWebTransportFactory(), decoderFactory = { MediaCodecOpusDecoder() }, playerFactory = { AudioTrackPlayer() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt index 9e1200cca..4363fd3d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt @@ -242,6 +242,10 @@ private fun OnStageControls( is BroadcastUiState.Broadcasting -> { MicMuteToggle(isMuted = broadcast.isMuted, onToggle = viewModel::setMicMuted) + // Stop sending audio without leaving the stage — viewer + // can pause the mic and resume later via Talk. Distinct + // from [LeaveStageButton], which also vacates the slot. + StopBroadcastButton(onClick = viewModel::stopBroadcast) LeaveStageButton(onClick = leaveStage) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/HandRaiseQueueSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/HandRaiseQueueSection.kt index 1c3001ffb..2d1421f82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/HandRaiseQueueSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/HandRaiseQueueSection.kt @@ -24,22 +24,29 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize 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.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel +import com.vitorpamplona.amethyst.commons.viewmodels.RoomPresence +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.participants.RoomParticipantActions import com.vitorpamplona.amethyst.ui.stringRes @@ -68,54 +75,79 @@ internal fun HandRaiseQueueSection( modifier: Modifier = Modifier, ) { val presences by viewModel.presences.collectAsState() + // Memoize the on-stage gate so a heartbeat that doesn't change + // the kind-30312 doesn't re-build the set every recompose. The + // event reference is stable across recompositions until the host + // republishes; presence updates run through the second remember. val onStageKeys = - event - .participants() - .filter { it.canSpeak() } - .map { it.pubKey } - .toSet() + remember(event) { + event + .participants() + .filter { it.canSpeak() } + .map { it.pubKey } + .toSet() + } val hands = - presences.values - .filter { it.handRaised && it.pubkey !in onStageKeys } - .sortedBy { it.updatedAtSec } + remember(presences, onStageKeys) { + presences.values + .filter { it.handRaised && it.pubkey !in onStageKeys } + .sortedBy { it.updatedAtSec } + } if (hands.isEmpty()) return val scope = rememberCoroutineScope() - Column(modifier = modifier.fillMaxWidth().padding(top = 12.dp)) { + Column(modifier = modifier.fillMaxSize().padding(top = 12.dp)) { Text( text = stringRes(R.string.nest_hand_raise_queue_title), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - hands.forEach { hand -> - Row( - modifier = Modifier.fillMaxWidth().padding(top = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - ClickableUserPicture( - baseUserHex = hand.pubkey, - size = Size35dp, + // LazyColumn so a flood of raised hands doesn't render every + // row every recompose. Stable key by pubkey lets Compose + // animate row entry/exit instead of teardown-rebuild. + LazyColumn(modifier = Modifier.fillMaxSize().padding(top = 6.dp)) { + items(items = hands, key = { it.pubkey }) { hand -> + HandRaiseRow( + hand = hand, accountViewModel = accountViewModel, - ) - Text( - text = hand.pubkey.take(8), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - ) - Spacer(Modifier.width(4.dp)) - Button( - onClick = { + onApprove = { scope.launch { val template = RoomParticipantActions.setRole(event, hand.pubkey, ROLE.SPEAKER) template?.let { runCatching { accountViewModel.account.signAndComputeBroadcast(it) } } } }, - ) { - Text(stringRes(R.string.nest_hand_raise_approve)) - } + ) } } } } + +@Composable +private fun HandRaiseRow( + hand: RoomPresence, + accountViewModel: AccountViewModel, + onApprove: () -> Unit, +) { + val user = remember(hand.pubkey) { LocalCache.getOrCreateUser(hand.pubkey) } + Row( + modifier = Modifier.fillMaxWidth().padding(top = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ClickableUserPicture( + baseUserHex = hand.pubkey, + size = Size35dp, + accountViewModel = accountViewModel, + ) + UsernameDisplay( + baseUser = user, + weight = Modifier.weight(1f), + accountViewModel = accountViewModel, + ) + Spacer(Modifier.width(4.dp)) + Button(onClick = onApprove) { + Text(stringRes(R.string.nest_hand_raise_approve)) + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f0e4c8d2a..caf123df0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -591,6 +591,10 @@ Minimize Minimize to keep listening Audio dropped + Loading room… + This room can\'t be opened + The room is missing an audio service or endpoint. The host needs to update its details before listeners can connect. + Back Start space Set up a nest server You haven\'t picked a nest server yet. Add %1$s to your server list and continue?\n\nYou can change this later in Settings.