From 208c90b2469d4f671922b17b5c2880d4e7e863f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 14:24:01 +0000 Subject: [PATCH] feat(audiorooms): host-leave confirmation dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the host taps "Leave" we now show a 3-button confirmation: - Close room → re-publish kind:30312 with status="closed", then leave - Just leave → leave; room stays open until the 8h staleness window - Cancel → dismiss Audience-side leave flow unchanged. Without this prompt, hosts who tap Leave silently abandoned the room — listeners saw it as "live" with no audio until the EGG-01 rule 7 staleness timeout, eight hours later. Implementation: - `closeMeetingSpace(accountViewModel, event)` builds a verbatim FormState from the event and reuses EditAudioRoomViewModel.buildEditTemplate(... STATUS.CLOSED). Bypasses the VM's mutable state so unsaved edits in an open Edit sheet can't leak into the close payload. - On publish failure we still leave (the user asked to) and surface a toast — the room will auto-close at the 8h timeout. Process death (host backgrounded + Android-killed) is still handled by the same 8h fallback; no extra wire changes. https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b --- .../audiorooms/room/AudioRoomFullScreen.kt | 92 ++++++++++++++++++- amethyst/src/main/res/values/strings.xml | 5 + 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt index 41a99ca97..de7f0d7af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt @@ -33,17 +33,21 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledTonalIconToggleButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -64,7 +68,9 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip19Bech32.toNAddr import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag +import kotlinx.coroutines.launch /** * Full-screen layout for [AudioRoomActivity]. Renders title + summary, @@ -98,7 +104,9 @@ internal fun AudioRoomFullScreen( ) { var showEditSheet by rememberSaveable { mutableStateOf(false) } var showHostMenu by rememberSaveable { mutableStateOf(false) } + var showHostLeaveConfirm by rememberSaveable { mutableStateOf(false) } val isHost = accountViewModel.account.signer.pubKey == event.pubKey + val leaveScope = rememberCoroutineScope() Row( modifier = Modifier.fillMaxWidth(), @@ -256,7 +264,15 @@ internal fun AudioRoomFullScreen( OutlinedButton(onClick = { showReactionPicker = true }) { Text(stringRes(R.string.audio_room_reactions_button)) } - OutlinedButton(onClick = onLeave) { + OutlinedButton( + onClick = { + if (isHost) { + showHostLeaveConfirm = true + } else { + onLeave() + } + }, + ) { Text(stringRes(R.string.audio_room_leave)) } } @@ -269,6 +285,44 @@ internal fun AudioRoomFullScreen( ) } + if (showHostLeaveConfirm) { + AlertDialog( + onDismissRequest = { showHostLeaveConfirm = false }, + title = { Text(stringRes(R.string.audio_room_leave_host_title)) }, + text = { Text(stringRes(R.string.audio_room_leave_host_body)) }, + confirmButton = { + TextButton( + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + onClick = { + showHostLeaveConfirm = false + leaveScope.launch { + val ok = closeMeetingSpace(accountViewModel, event) + if (!ok) { + accountViewModel.toastManager.toast( + R.string.audio_rooms, + R.string.audio_room_leave_host_close_failed, + ) + } + onLeave() + } + }, + ) { + Text(stringRes(R.string.audio_room_leave_host_close)) + } + }, + dismissButton = { + TextButton( + onClick = { + showHostLeaveConfirm = false + onLeave() + }, + ) { + Text(stringRes(R.string.audio_room_leave_host_just_leave)) + } + }, + ) + } + AudioRoomChatPanel( roomATag = ATag( @@ -498,6 +552,42 @@ private fun TalkRow( } } +/** + * Re-publish the host's [event] with `status="closed"`, preserving every + * other field verbatim (room name, summary, image, service, endpoint, + * participants). Returns true on success. + * + * Reuses [EditAudioRoomViewModel.buildEditTemplate] so the close-on-leave + * path emits the same shape as the explicit Edit → Close room action. + * Decoupled from [EditAudioRoomViewModel] state so the host's unsaved + * edits in the Edit sheet (if any) cannot accidentally leak into the + * close payload. + */ +private suspend fun closeMeetingSpace( + accountViewModel: AccountViewModel, + event: MeetingSpaceEvent, +): Boolean { + val verbatim = + EditAudioRoomViewModel.FormState( + dTag = event.dTag(), + roomName = event.room().orEmpty(), + summary = event.summary().orEmpty(), + imageUrl = event.image().orEmpty(), + endpointUrl = event.endpoint().orEmpty(), + serviceUrl = event.service().orEmpty(), + isPublishing = false, + error = null, + ) + return try { + val template = + EditAudioRoomViewModel.buildEditTemplate(event, verbatim, StatusTag.STATUS.CLOSED) + accountViewModel.account.signAndComputeBroadcast(template) + true + } catch (_: Throwable) { + false + } +} + /** * Build an `nostr:naddr1...` URI for the room and hand it to the * system share sheet. Includes the room title in the share text diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index cd621dc77..c4565defc 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -529,6 +529,11 @@ Stop Join audio room Leave + End this audio room? + You\'re the host. Closing the room will disconnect everyone. Choose \"Just leave\" if you want to come back later — the room will auto-close after 8 hours of inactivity. + Close room + Just leave + Couldn\'t mark room as closed. Leaving anyway — room will auto-close. %1$d listener %1$d listeners