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