fix(nests): UI polish — Stop button, unjoinable state, hand queue, DST

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
This commit is contained in:
Claude
2026-05-01 19:25:36 +00:00
parent fc79faa468
commit ed89e2d542
6 changed files with 213 additions and 80 deletions
@@ -331,23 +331,25 @@ private fun ScheduleStartPicker(
TextButton(onClick = { TextButton(onClick = {
val dayMillisUtc = datePickerState.selectedDateMillis val dayMillisUtc = datePickerState.selectedDateMillis
if (dayMillisUtc != null) { if (dayMillisUtc != null) {
// DatePicker hands back UTC midnight of the selected // DatePicker hands back UTC midnight of the
// calendar date. Add the picked hour:minute (local) // selected calendar date. Reinterpret that
// to its seconds, then subtract the local offset to // calendar date as local + the picked
// land on the correct UTC unix second — same shape // hour:minute, then convert to UTC seconds
// as ExpirationDatePicker. // using the zone offset AT THE PICKED INSTANT.
val localSeconds = // The previous code used `Instant.now()`'s
dayMillisUtc / 1000L + // offset, which was off by one hour for rooms
timePickerState.hour * 3600L + // scheduled across a DST transition.
timePickerState.minute * 60L val zone = java.time.ZoneId.systemDefault()
val offsetSec = val localDate =
java.time.ZoneId java.time.Instant
.systemDefault() .ofEpochMilli(dayMillisUtc)
.rules .atZone(java.time.ZoneOffset.UTC)
.getOffset(java.time.Instant.now()) .toLocalDate()
.totalSeconds val pickedZdt =
.toLong() localDate
onChange(localSeconds - offsetSec) .atTime(timePickerState.hour, timePickerState.minute)
.atZone(zone)
onChange(pickedZdt.toEpochSecond())
} }
showTime = false showTime = false
}) { Text(stringRes(R.string.nest_create_submit)) } }) { Text(stringRes(R.string.nest_create_submit)) }
@@ -21,6 +21,15 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity
import androidx.activity.compose.BackHandler 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.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -28,6 +37,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue 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.model.AddressableNote
import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel 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.lifecycle.rememberNestViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.screen.NestFullScreen 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.screen.loggedIn.nests.room.screen.NestPipScreen
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.nestsclient.NestsRoomConfig import com.vitorpamplona.nestsclient.NestsRoomConfig
import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
@@ -71,45 +86,117 @@ internal fun NestActivityContent(
) { ) {
val parsedAddress = remember(addressValue) { Address.parse(addressValue) } val parsedAddress = remember(addressValue) { Address.parse(addressValue) }
if (parsedAddress == null) { if (parsedAddress == null) {
// Malformed address — bail. The Activity will receive a Closed // Malformed address — show the unjoinable state so the user
// signal through the VM teardown when the user finishes; until // gets a clear path back instead of staring at a black screen
// then we just render nothing. // while the Activity sits behind a useless extra.
UnjoinableRoomState(onLeave = onLeave)
return return
} }
LoadAddressableNote(parsedAddress, accountViewModel) { addressableNote -> 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<MeetingSpaceEvent>(addressableNote, accountViewModel) val event by observeNoteEvent<MeetingSpaceEvent>(addressableNote, accountViewModel)
event?.let { val current = event
val service = it.service() if (current == null) {
val endPoint = it.endpoint() LoadingRoomState()
if (service != null && endPoint != null) { return@LoadAddressableNote
val viewModel = }
rememberNestViewModel( val service = current.service()
NestsRoomConfig( val endPoint = current.endpoint()
authBaseUrl = service, if (service.isNullOrBlank() || endPoint.isNullOrBlank()) {
endpoint = endPoint, UnjoinableRoomState(onLeave = onLeave)
hostPubkey = it.pubKey, return@LoadAddressableNote
roomId = it.dTag(), }
kind = it.kind, val viewModel =
), rememberNestViewModel(
accountViewModel.account.signer, NestsRoomConfig(
) authBaseUrl = service,
endpoint = endPoint,
hostPubkey = current.pubKey,
roomId = current.dTag(),
kind = current.kind,
),
accountViewModel.account.signer,
)
NestActivityBody( NestActivityBody(
event = it, event = current,
roomNote = addressableNote, roomNote = addressableNote,
viewModel = viewModel, viewModel = viewModel,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
isInPipMode = isInPipMode, isInPipMode = isInPipMode,
isPipSupported = isPipSupported, isPipSupported = isPipSupported,
onMuteState = onMuteState, onMuteState = onMuteState,
onConnectedChange = onConnectedChange, onConnectedChange = onConnectedChange,
pipMuteSignal = pipMuteSignal, pipMuteSignal = pipMuteSignal,
onLeave = onLeave, onLeave = onLeave,
onMinimize = onMinimize, 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))
} }
} }
} }
@@ -47,7 +47,11 @@ internal class NestViewModelFactory(
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = override fun <T : ViewModel> create(modelClass: Class<T>): T =
NestViewModel( 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(), transport = QuicWebTransportFactory(),
decoderFactory = { MediaCodecOpusDecoder() }, decoderFactory = { MediaCodecOpusDecoder() },
playerFactory = { AudioTrackPlayer() }, playerFactory = { AudioTrackPlayer() },
@@ -242,6 +242,10 @@ private fun OnStageControls(
is BroadcastUiState.Broadcasting -> { is BroadcastUiState.Broadcasting -> {
MicMuteToggle(isMuted = broadcast.isMuted, onToggle = viewModel::setMicMuted) 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) LeaveStageButton(onClick = leaveStage)
} }
@@ -24,22 +24,29 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width 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.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel 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.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.participants.RoomParticipantActions import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.participants.RoomParticipantActions
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -68,54 +75,79 @@ internal fun HandRaiseQueueSection(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val presences by viewModel.presences.collectAsState() 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 = val onStageKeys =
event remember(event) {
.participants() event
.filter { it.canSpeak() } .participants()
.map { it.pubKey } .filter { it.canSpeak() }
.toSet() .map { it.pubKey }
.toSet()
}
val hands = val hands =
presences.values remember(presences, onStageKeys) {
.filter { it.handRaised && it.pubkey !in onStageKeys } presences.values
.sortedBy { it.updatedAtSec } .filter { it.handRaised && it.pubkey !in onStageKeys }
.sortedBy { it.updatedAtSec }
}
if (hands.isEmpty()) return if (hands.isEmpty()) return
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Column(modifier = modifier.fillMaxWidth().padding(top = 12.dp)) { Column(modifier = modifier.fillMaxSize().padding(top = 12.dp)) {
Text( Text(
text = stringRes(R.string.nest_hand_raise_queue_title), text = stringRes(R.string.nest_hand_raise_queue_title),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
hands.forEach { hand -> // LazyColumn so a flood of raised hands doesn't render every
Row( // row every recompose. Stable key by pubkey lets Compose
modifier = Modifier.fillMaxWidth().padding(top = 6.dp), // animate row entry/exit instead of teardown-rebuild.
verticalAlignment = Alignment.CenterVertically, LazyColumn(modifier = Modifier.fillMaxSize().padding(top = 6.dp)) {
horizontalArrangement = Arrangement.spacedBy(8.dp), items(items = hands, key = { it.pubkey }) { hand ->
) { HandRaiseRow(
ClickableUserPicture( hand = hand,
baseUserHex = hand.pubkey,
size = Size35dp,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) onApprove = {
Text(
text = hand.pubkey.take(8),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(4.dp))
Button(
onClick = {
scope.launch { scope.launch {
val template = RoomParticipantActions.setRole(event, hand.pubkey, ROLE.SPEAKER) val template = RoomParticipantActions.setRole(event, hand.pubkey, ROLE.SPEAKER)
template?.let { runCatching { accountViewModel.account.signAndComputeBroadcast(it) } } 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))
}
}
}
+4
View File
@@ -591,6 +591,10 @@
<string name="nest_minimize">Minimize</string> <string name="nest_minimize">Minimize</string>
<string name="nest_minimize_description">Minimize to keep listening</string> <string name="nest_minimize_description">Minimize to keep listening</string>
<string name="nest_audio_dropped">Audio dropped</string> <string name="nest_audio_dropped">Audio dropped</string>
<string name="nest_loading_room">Loading room…</string>
<string name="nest_unjoinable_title">This room can\'t be opened</string>
<string name="nest_unjoinable_body">The room is missing an audio service or endpoint. The host needs to update its details before listeners can connect.</string>
<string name="nest_unjoinable_back">Back</string>
<string name="nest_create_fab">Start space</string> <string name="nest_create_fab">Start space</string>
<string name="nest_no_server_title">Set up a nest server</string> <string name="nest_no_server_title">Set up a nest server</string>
<string name="nest_no_server_body">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.</string> <string name="nest_no_server_body">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.</string>