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 = {
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)) }
@@ -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<MeetingSpaceEvent>(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))
}
}
}
@@ -47,7 +47,11 @@ internal class NestViewModelFactory(
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): 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() },
@@ -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)
}
@@ -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))
}
}
}
+4
View File
@@ -591,6 +591,10 @@
<string name="nest_minimize">Minimize</string>
<string name="nest_minimize_description">Minimize to keep listening</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_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>