From e041c77832537f243176c7f9fe2eed4cf8e3a60c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 02:52:55 +0000 Subject: [PATCH] fix(audio-rooms): UX rough edges from the screen walk (audit walk #5-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../amethyst/ui/note/types/MeetingSpace.kt | 26 +++++++++---- .../audiorooms/create/CreateAudioRoomSheet.kt | 34 +++++++++++++++-- .../create/CreateAudioRoomViewModel.kt | 14 +++++++ .../audiorooms/room/EditAudioRoomSheet.kt | 38 ++++++++++++++++--- .../room/ParticipantHostActionsSheet.kt | 33 ++++++++++------ 5 files changed, 116 insertions(+), 29 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt index e8071ca2b..cd212331a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt @@ -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, ) } }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomSheet.kt index a0ebc878c..38eb464d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomSheet.kt @@ -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)) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomViewModel.kt index 63ff080df..a047168ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomViewModel.kt @@ -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 " 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/EditAudioRoomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/EditAudioRoomSheet.kt index 8f500352a..14dd4d8f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/EditAudioRoomSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/EditAudioRoomSheet.kt @@ -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)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/ParticipantHostActionsSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/ParticipantHostActionsSheet.kt index 7aedc495c..87f4dbb86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/ParticipantHostActionsSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/ParticipantHostActionsSheet.kt @@ -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()