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:
@@ -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,
|
||||
)
|
||||
}
|
||||
}) {
|
||||
|
||||
+30
-4
@@ -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))
|
||||
}
|
||||
},
|
||||
|
||||
+14
@@ -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
|
||||
|
||||
+32
-6
@@ -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))
|
||||
}
|
||||
|
||||
+21
-12
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user