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) {
recording?.let {
ListenToRecordingButton(url = it)
ListenToRecordingButton(url = it, accountViewModel = accountViewModel)
}
} else {
com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room.JoinAudioRoomButton(
@@ -191,14 +191,26 @@ fun RenderMeetingSpaceEventInner(
* purpose — Amethyst doesn't ship its own audio player surface.
*/
@Composable
private fun ListenToRecordingButton(url: String) {
private fun ListenToRecordingButton(
url: String,
accountViewModel: AccountViewModel,
) {
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 = {
runCatching {
context.startActivity(
android.content
.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK),
val launched =
runCatching {
context.startActivity(
android.content
.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))
.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,
)
}
}) {
@@ -253,6 +253,10 @@ private fun ScheduleStartPicker(
.atZone(java.time.ZoneId.systemDefault())
.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 =
androidx.compose.material3.rememberDatePickerState(
initialSelectedDateMillis = initialMillis,
@@ -264,6 +268,16 @@ private fun ScheduleStartPicker(
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(
onClick = { showDate = true },
modifier = Modifier.fillMaxWidth(),
@@ -273,7 +287,10 @@ private fun ScheduleStartPicker(
if (showDate) {
androidx.compose.material3.DatePickerDialog(
onDismissRequest = { showDate = false },
onDismissRequest = {
resetPickersToCommitted()
showDate = false
},
confirmButton = {
TextButton(onClick = {
showDate = false
@@ -281,7 +298,10 @@ private fun ScheduleStartPicker(
}) { Text(stringRes(R.string.next)) }
},
dismissButton = {
TextButton(onClick = { showDate = false }) {
TextButton(onClick = {
resetPickersToCommitted()
showDate = false
}) {
Text(stringRes(R.string.audio_room_create_cancel))
}
},
@@ -293,7 +313,10 @@ private fun ScheduleStartPicker(
if (showTime) {
androidx.compose.material3.TimePickerDialog(
title = { Text(stringRes(R.string.audio_room_create_when)) },
onDismissRequest = { showTime = false },
onDismissRequest = {
resetPickersToCommitted()
showTime = false
},
confirmButton = {
TextButton(onClick = {
val dayMillisUtc = datePickerState.selectedDateMillis
@@ -320,7 +343,10 @@ private fun ScheduleStartPicker(
}) { Text(stringRes(R.string.audio_room_create_submit)) }
},
dismissButton = {
TextButton(onClick = { showTime = false }) {
TextButton(onClick = {
resetPickersToCommitted()
showTime = false
}) {
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.") }
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) }
val accountModel = account.account
@@ -43,8 +43,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -79,6 +81,32 @@ fun EditAudioRoomSheet(
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
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) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
@@ -135,15 +163,13 @@ fun EditAudioRoomSheet(
Spacer(Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
// 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(
enabled = !state.isPublishing,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
onClick = {
scope.launch {
if (viewModel.closeRoom()) onDismiss()
}
},
onClick = { confirmCloseOpen = true },
) {
Text(stringRes(R.string.audio_room_close_action))
}
@@ -145,20 +145,29 @@ internal fun ParticipantHostActionsSheet(
// audio-room foreground service keeps audio alive while
// the user is on the profile screen.
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)) {
val npub = NPub.create(target)
runCatching {
context.startActivity(
android.content
.Intent(android.content.Intent.ACTION_VIEW)
.apply {
data = android.net.Uri.parse("nostr:$npub")
setClass(context, MainActivity::class.java)
addFlags(
android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
android.content.Intent.FLAG_ACTIVITY_REORDER_TO_FRONT,
)
},
val launched =
runCatching {
context.startActivity(
android.content
.Intent(android.content.Intent.ACTION_VIEW)
.apply {
data = android.net.Uri.parse("nostr:$npub")
setClass(context, MainActivity::class.java)
addFlags(
android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
android.content.Intent.FLAG_ACTIVITY_REORDER_TO_FRONT,
)
},
)
}.isSuccess
if (!launched) {
accountViewModel.toastManager.toast(
R.string.audio_room_chat_send_failed_title,
noAppMessage,
user = null,
)
}
onDismiss()