fix(audio-rooms): UX rough edges from the screen walk (audit walk #5-8)

Four user-visible quirks the walkthrough surfaced:

  5. EditAudioRoomSheet's "Close room" button was one tap with no
     confirm. A misclick destroyed the room. Added an AlertDialog
     gate ("All attendees will be disconnected. The room will
     show as CLOSED in the feed.") with a destructive primary
     action and a Cancel.

  6. Silent ActivityNotFoundException in ParticipantHostActionsSheet
     (View Profile) and MeetingSpace.kt (Listen to Recording).
     Both were `runCatching { startActivity(...) }` with no toast
     on failure — user taps, nothing happens, no feedback. Now
     they toast "No app installed to open this link." through
     the standard toastManager.

  7. ScheduleStartPicker dismiss didn't revert in-dialog state.
     User picks Dec 13, taps Cancel, reopens the dialog → still
     pre-selected to Dec 13 (the cancelled choice) instead of the
     committed value. Added `resetPickersToCommitted()` that
     rewinds both picker states to the committed unixSeconds on
     every dismiss / cancel path.

  8. CreateAudioRoomViewModel.publishAndBuildLaunchInfo accepted
     past start times. Added a `scheduledStartUnix < now` guard so
     the host gets "Pick a future start time." instead of publishing
     a kind-30312 with `status=planned` + a backdated `starts` tag.
This commit is contained in:
Claude
2026-04-27 02:52:55 +00:00
parent 9af95103e8
commit e041c77832
5 changed files with 116 additions and 29 deletions
@@ -172,7 +172,7 @@ fun RenderMeetingSpaceEventInner(
) { ) {
if (status == MeetingSpaceStatusTag.STATUS.CLOSED) { if (status == MeetingSpaceStatusTag.STATUS.CLOSED) {
recording?.let { recording?.let {
ListenToRecordingButton(url = it) ListenToRecordingButton(url = it, accountViewModel = accountViewModel)
} }
} else { } else {
com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room.JoinAudioRoomButton( com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room.JoinAudioRoomButton(
@@ -191,15 +191,27 @@ fun RenderMeetingSpaceEventInner(
* purpose — Amethyst doesn't ship its own audio player surface. * purpose — Amethyst doesn't ship its own audio player surface.
*/ */
@Composable @Composable
private fun ListenToRecordingButton(url: String) { private fun ListenToRecordingButton(
url: String,
accountViewModel: AccountViewModel,
) {
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
val noAppMessage = stringRes(R.string.audio_room_no_app_to_open_link)
androidx.compose.material3.OutlinedButton(onClick = { androidx.compose.material3.OutlinedButton(onClick = {
val launched =
runCatching { runCatching {
context.startActivity( context.startActivity(
android.content android.content
.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)) .Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK), .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK),
) )
}.isSuccess
if (!launched) {
accountViewModel.toastManager.toast(
R.string.audio_room_chat_send_failed_title,
noAppMessage,
user = null,
)
} }
}) { }) {
Text(stringRes(R.string.audio_room_listen_to_recording)) Text(stringRes(R.string.audio_room_listen_to_recording))
@@ -253,6 +253,10 @@ private fun ScheduleStartPicker(
.atZone(java.time.ZoneId.systemDefault()) .atZone(java.time.ZoneId.systemDefault())
.toLocalDateTime() .toLocalDateTime()
// Re-key the picker state on `unixSeconds` so the dialog opens
// pre-populated with the COMMITTED value. Without this key, a
// mid-edit Cancel would leave the in-dialog state reflecting
// the half-typed cancelled choice on the next reopen.
val datePickerState = val datePickerState =
androidx.compose.material3.rememberDatePickerState( androidx.compose.material3.rememberDatePickerState(
initialSelectedDateMillis = initialMillis, initialSelectedDateMillis = initialMillis,
@@ -264,6 +268,16 @@ private fun ScheduleStartPicker(
is24Hour = false, is24Hour = false,
) )
// On dismiss (Cancel or back-press), restore the picker states
// to the committed `unixSeconds`. rememberDatePickerState /
// TimePickerState are reference-stable across recompositions
// and survive `cancel`, so we have to rewind them by hand.
fun resetPickersToCommitted() {
datePickerState.selectedDateMillis = initialMillis
timePickerState.hour = initialLocal.hour
timePickerState.minute = initialLocal.minute
}
androidx.compose.material3.OutlinedButton( androidx.compose.material3.OutlinedButton(
onClick = { showDate = true }, onClick = { showDate = true },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -273,7 +287,10 @@ private fun ScheduleStartPicker(
if (showDate) { if (showDate) {
androidx.compose.material3.DatePickerDialog( androidx.compose.material3.DatePickerDialog(
onDismissRequest = { showDate = false }, onDismissRequest = {
resetPickersToCommitted()
showDate = false
},
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
showDate = false showDate = false
@@ -281,7 +298,10 @@ private fun ScheduleStartPicker(
}) { Text(stringRes(R.string.next)) } }) { Text(stringRes(R.string.next)) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showDate = false }) { TextButton(onClick = {
resetPickersToCommitted()
showDate = false
}) {
Text(stringRes(R.string.audio_room_create_cancel)) Text(stringRes(R.string.audio_room_create_cancel))
} }
}, },
@@ -293,7 +313,10 @@ private fun ScheduleStartPicker(
if (showTime) { if (showTime) {
androidx.compose.material3.TimePickerDialog( androidx.compose.material3.TimePickerDialog(
title = { Text(stringRes(R.string.audio_room_create_when)) }, title = { Text(stringRes(R.string.audio_room_create_when)) },
onDismissRequest = { showTime = false }, onDismissRequest = {
resetPickersToCommitted()
showTime = false
},
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
val dayMillisUtc = datePickerState.selectedDateMillis val dayMillisUtc = datePickerState.selectedDateMillis
@@ -320,7 +343,10 @@ private fun ScheduleStartPicker(
}) { Text(stringRes(R.string.audio_room_create_submit)) } }) { Text(stringRes(R.string.audio_room_create_submit)) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showTime = false }) { TextButton(onClick = {
resetPickersToCommitted()
showTime = false
}) {
Text(stringRes(R.string.audio_room_create_cancel)) Text(stringRes(R.string.audio_room_create_cancel))
} }
}, },
@@ -121,6 +121,20 @@ class CreateAudioRoomViewModel : ViewModel() {
_state.update { it.copy(error = "Pick a start time for the scheduled room.") } _state.update { it.copy(error = "Pick a start time for the scheduled room.") }
return null return null
} }
// The host may want to backdate a "scheduled" announcement
// for a room that already started elsewhere — but more often
// a past timestamp means the picker landed on the wrong day
// and the host didn't notice. Guard against the misfire.
// (Audience-side render is already past-aware: SCHEDULED
// chips suppress the "Starts <date>" subline once `now` >
// starts.)
val nowSec =
com.vitorpamplona.quartz.utils.TimeUtils
.now()
if (current.scheduled && current.scheduledStartUnix < nowSec) {
_state.update { it.copy(error = "Pick a future start time.") }
return null
}
_state.update { it.copy(isPublishing = true, error = null) } _state.update { it.copy(isPublishing = true, error = null) }
val accountModel = account.account val accountModel = account.account
@@ -43,8 +43,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -79,6 +81,32 @@ fun EditAudioRoomSheet(
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var confirmCloseOpen by remember { mutableStateOf(false) }
if (confirmCloseOpen) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { confirmCloseOpen = false },
title = { Text(stringRes(R.string.audio_room_close_room_confirm_title)) },
text = { Text(stringRes(R.string.audio_room_close_room_confirm_body)) },
confirmButton = {
TextButton(
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
onClick = {
confirmCloseOpen = false
scope.launch {
if (viewModel.closeRoom()) onDismiss()
}
},
) {
Text(stringRes(R.string.audio_room_close_room_confirm_action))
}
},
dismissButton = {
TextButton(onClick = { confirmCloseOpen = false }) {
Text(stringRes(R.string.audio_room_create_cancel))
}
},
)
}
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column( Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
@@ -135,15 +163,13 @@ fun EditAudioRoomSheet(
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
// Destructive — flips status to CLOSED with the same d-tag // Destructive — flips status to CLOSED with the same d-tag
// so subscribers see the room go dark. // so subscribers see the room go dark. Confirm prompt
// gates the actual publish so a misclick on the
// edit-sheet's bottom row doesn't kill the room.
TextButton( TextButton(
enabled = !state.isPublishing, enabled = !state.isPublishing,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
onClick = { onClick = { confirmCloseOpen = true },
scope.launch {
if (viewModel.closeRoom()) onDismiss()
}
},
) { ) {
Text(stringRes(R.string.audio_room_close_action)) Text(stringRes(R.string.audio_room_close_action))
} }
@@ -145,8 +145,10 @@ internal fun ParticipantHostActionsSheet(
// audio-room foreground service keeps audio alive while // audio-room foreground service keeps audio alive while
// the user is on the profile screen. // the user is on the profile screen.
val context = LocalContext.current val context = LocalContext.current
val noAppMessage = stringRes(R.string.audio_room_no_app_to_open_link)
ActionRow(stringRes(R.string.audio_room_participant_view_profile)) { ActionRow(stringRes(R.string.audio_room_participant_view_profile)) {
val npub = NPub.create(target) val npub = NPub.create(target)
val launched =
runCatching { runCatching {
context.startActivity( context.startActivity(
android.content android.content
@@ -160,6 +162,13 @@ internal fun ParticipantHostActionsSheet(
) )
}, },
) )
}.isSuccess
if (!launched) {
accountViewModel.toastManager.toast(
R.string.audio_room_chat_send_failed_title,
noAppMessage,
user = null,
)
} }
onDismiss() onDismiss()
} }